operations-payroll.component.ts 10.0 KB

12345678910111213141516171819202122232425262728293031
  1. import { CommonModule } from '@angular/common';
  2. import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
  3. import { FormsModule } from '@angular/forms';
  4. import { Banknote, Download, Eye, FileCheck2, LucideAngularModule, Plus, RefreshCw, Search, Settings2, TrendingUp, X } from 'lucide-angular';
  5. import { utils, writeFile } from 'xlsx';
  6. import { OperationsPage, PayRate, PayrollCoachSummary, PayrollDetail, PayrollLine, PayrollOverview, PayrollSummary } from '../admin.models';
  7. import { AdminSessionService } from '../admin-session.service';
  8. import { OperationsService } from '../operations.service';
  9. const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
  10. @Component({selector:'app-operations-payroll',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
  11. export class OperationsPayrollComponent implements OnInit{
  12. readonly overview=signal<PayrollOverview|null>(null);readonly page=signal<OperationsPage<PayrollSummary>|null>(null);readonly detail=signal<PayrollDetail|null>(null);readonly rates=signal<PayRate[]>([]);readonly loading=signal(true);readonly refreshing=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'overview'|'batches'|'rates'>('overview');readonly month=signal(localDateKey(new Date()).slice(0,7));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 adjustmentCoachId=signal(0);readonly adjustmentAmount=signal(0);readonly adjustmentReason=signal('');readonly paymentMethod=signal('银行转账');readonly paymentReference=signal('');readonly icons={Banknote,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;});private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
  13. ngOnInit():void{void this.load(true);}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
  14. async load(refresh=false):Promise<void>{this.loading.set(true);this.error.set('');try{const [overview,page,rates]=await Promise.all([this.operations.payrollOverview(this.month(),this.storeId(),this.search(),refresh),this.operations.payrolls(),this.operations.payRates()]);this.overview.set(overview);this.page.set(page);this.rates.set(rates.items);}catch(error){this.error.set(error instanceof Error?error.message:'工资数据加载失败');}finally{this.loading.set(false);}}
  15. async refreshLive():Promise<void>{if(this.refreshing())return;this.refreshing.set(true);this.error.set('');try{this.overview.set(await this.operations.payrollOverview(this.month(),this.storeId(),this.search(),true));this.message.set('实时上课记录已刷新');}catch(error){this.error.set(error instanceof Error?error.message:'实时工资数据刷新失败,当前继续显示缓存');}finally{this.refreshing.set(false);}}
  16. async applyFilters():Promise<void>{await this.load(true);}
  17. async open(row:PayrollSummary):Promise<void>{this.loading.set(true);this.error.set('');try{this.selectedCoachId.set(0);this.detail.set(await this.operations.payrollDetail(row.objectId));}catch(error){this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{this.loading.set(false);}}
  18. coachLabel(name:string,coachId:number):string{const value=String(name||'').trim();return !value||value===String(coachId)?`陪练 ${coachId}`:value;}
  19. 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.detail.set(null);this.selectedCoachId.set(0);this.selectedCoachName.set('');}
  20. async create():Promise<void>{if(!this.overview()?.sourceStatus.available){this.error.set('实时上课记录不可用,不能生成工资草稿');return;}this.saving.set(true);this.error.set('');try{const result=await this.operations.mutate<PayrollSummary>('ops/payroll/create','',`生成 ${this.month()} 月全公司工资草稿`,{month:this.month()});await this.load();await this.open(result);this.message.set('全公司工资草稿已生成,尚未审核或发放');this.tab.set('batches');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
  21. 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);}}
  22. async addAdjustment():Promise<void>{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.operations.mutate('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);}}
  23. async refreshDraft():Promise<void>{const batch=this.detail();if(!batch||batch.status!=='draft')return;this.saving.set(true);this.error.set('');try{this.detail.set(await this.operations.mutate<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);}}
  24. 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.operations.mutate('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);}}
  25. async review():Promise<void>{const batch=this.detail();if(!batch)return;this.saving.set(true);this.error.set('');try{await this.operations.mutate('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);}}
  26. async markPaid():Promise<void>{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.operations.mutate('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);}}
  27. 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());const summary=data.coachItems.map((row)=>({'陪练老师':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.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),'异常记录');writeFile(book,data.fileName);}catch(error){this.error.set(error instanceof Error?error.message:'工资 Excel 导出失败');}finally{this.saving.set(false);}}
  28. private exportLine(line:PayrollLine):Record<string,unknown>{return{'来源记录ID':line.legacyGeneralId,'上课日期':line.lessonAt,'结算月份':line.settlementMonth,'原上课月份':line.originalMonth,'陪练老师':this.coachLabel(line.coachName,line.coachId),'门店':line.storeName,'学员':line.studentName,'课程':line.courseName,'课型':line.classTypeLabel,'工时':line.durationHours,'单价':line.rateAmount,'金额':line.amount,'说明':line.description};}
  29. }