Explorar o código

fix: show payroll cache before live refresh

彭峰 hai 1 mes
pai
achega
063b11d992

+ 1 - 1
scripts/deploy-admin-functions.mjs

@@ -2803,7 +2803,7 @@ async function cachedPayrollLessons(context,input,month) {
   const companyId=companyIdOf(context,input),raw=await Psql.query('SELECT l.*,c."objectId" AS "lessonObjectId",c."generalId",c."createdAt" AS "commonCreatedAt",a."jssj" AS "appointmentEnd",u."realName" AS "coachRealName",u."nickname" AS "coachNickname",u."username" AS "coachUsername",u."legacyUserData" AS "coachLegacy" FROM "LessonRecordFact" l LEFT JOIN "CommonModel" c ON c."company"=l."company" AND CAST(c."modelId" AS text)=\'59\' AND CAST(c."generalId" AS text)=CAST(l."legacyGeneralId" AS text) LEFT JOIN "CommonModel" ac ON ac."company"=l."company" AND CAST(ac."modelId" AS text)=\'54\' AND CAST(ac."generalId" AS text)=CAST(l."yyds" AS text) LEFT JOIN "CourseAppointment" a ON a."company"=l."company" AND CAST(a."id" AS text)=CAST(ac."itemId" AS text) LEFT JOIN "_User" u ON u."company"=l."company" AND COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\')=CAST(l."jsmz" AS text) WHERE ($1::text IS NULL OR l."company"=$1)',[companyId]);
   return raw.map((row)=>{const legacy=legacyOf({legacyUserData:row.coachLegacy});return payrollSourceProjection({...row,legacyGeneralId:row.legacyGeneralId||row.generalId,coachName:row.coachName||row.coachRealName||row.coachNickname||legacy.HoneyName||row.coachUsername,studentName:row.studentName||row.xymz,courseName:row.courseName||row.kcmc});},'cache').filter((row)=>row.lessonAt.slice(0,7)===month);
 }
-async function payrollSourceRows(context,input,month,requireLive=false) { const live=await livePayrollLessons(month),items=live.available?live.items:await cachedPayrollLessons(context,input,month);if(requireLive&&!live.available)fail(503,'实时上课记录不可用,当前禁止生成或审核工资单');const companyId=companyIdOf(context,input),users=await Psql.query('SELECT u.*,p."realName" AS "storeRealName",p."nickname" AS "storeNickname",p."username" AS "storeUsername",p."legacyUserData" AS "storeLegacy" FROM "_User" u LEFT JOIN "_User" p ON p."company"=u."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') WHERE ($1::text IS NULL OR u."company"=$1)',[companyId]).catch(()=>[]),userMap=new Map(users.map((row)=>[number(row.legacyUserId||legacyOf(row).UserID),row]));for(const item of items){const coach=userMap.get(item.coachId);if(!coach)continue;const legacy=legacyOf(coach),storeLegacy=legacyOf({legacyUserData:coach.storeLegacy});item.coachName=displayName(coach);item.coachMobile=mobileOf(coach);item.storeId=item.storeId||number(legacy.ParentUserID);item.storeName=text(coach.storeRealName||coach.storeNickname||storeLegacy.HoneyName||coach.storeUsername)||'未绑定门店';}return{items,sourceStatus:{available:live.available,mode:live.available?'live':'cache',message:live.available?live.message:live.message+',当前显示缓存数据',total:live.total,refreshedAt:live.refreshedAt||new Date().toISOString()}}; }
+async function payrollSourceRows(context,input,month,requireLive=false) { const shouldRefresh=requireLive||input.refresh!==false,live=shouldRefresh?await livePayrollLessons(month):{available:false,message:'正在后台刷新旧系统实时上课记录',items:[],total:0,refreshedAt:''},items=live.available?live.items:await cachedPayrollLessons(context,input,month);if(requireLive&&!live.available)fail(503,'实时上课记录不可用,当前禁止生成或审核工资单');const companyId=companyIdOf(context,input),users=await Psql.query('SELECT u.*,p."realName" AS "storeRealName",p."nickname" AS "storeNickname",p."username" AS "storeUsername",p."legacyUserData" AS "storeLegacy" FROM "_User" u LEFT JOIN "_User" p ON p."company"=u."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') WHERE ($1::text IS NULL OR u."company"=$1)',[companyId]).catch(()=>[]),userMap=new Map(users.map((row)=>[number(row.legacyUserId||legacyOf(row).UserID),row]));for(const item of items){const coach=userMap.get(item.coachId);if(!coach)continue;const legacy=legacyOf(coach),storeLegacy=legacyOf({legacyUserData:coach.storeLegacy});item.coachName=displayName(coach);item.coachMobile=mobileOf(coach);item.storeId=item.storeId||number(legacy.ParentUserID);item.storeName=text(coach.storeRealName||coach.storeNickname||storeLegacy.HoneyName||coach.storeUsername)||'未绑定门店';}return{items,sourceStatus:{available:live.available,mode:live.available?'live':'cache',message:live.available?live.message:live.message+',当前显示缓存数据',total:live.available?live.total:items.length,refreshedAt:live.refreshedAt||new Date().toISOString()}}; }
 async function newClassObjects(className,context,input,limit=1000) { try { const fields=await fieldsOf(className).catch(()=>({})); if(!Object.keys(fields).length)return[]; const query=new Parse.Query(className); const company=companyPointer(context,input); if(company&&fields.company) query.equalTo('company',company); query.limit(limit); query.descending(fields.createdAt?'createdAt':'objectId'); return await query.find({useMasterKey:true}); } catch(error) { if(error.code===100||error.code===103||/class|schema|column .* does not exist/i.test(text(error.message))) return []; throw error; } }
 async function availabilityRows(context,input,coachId=0) { const rows=await newClassObjects('CoachAvailability',context,input,1000); return rows.filter((row)=>!coachId||number(row.get('coachId'))===coachId).map((row)=>({objectId:row.id,coachId:number(row.get('coachId')),ruleType:text(row.get('ruleType'))||'weekly',weekday:number(row.get('weekday')),date:text(row.get('date')),startTime:text(row.get('startTime')),endTime:text(row.get('endTime')),available:row.get('available')!==false,reason:text(row.get('reason')),updatedAt:dateIso(row.updatedAt)})); }
 function defaultPayRates() { return [{objectId:'default-1',classType:1,classTypeLabel:classType(1),amount:20,durationHours:.5,effectiveFrom:'1970-01-01',active:true,isDefault:true},{objectId:'default-2',classType:2,classTypeLabel:classType(2),amount:40,durationHours:1,effectiveFrom:'1970-01-01',active:true,isDefault:true},{objectId:'default-3',classType:3,classTypeLabel:classType(3),amount:40,durationHours:1,effectiveFrom:'1970-01-01',active:true,isDefault:true}]; }

+ 1 - 1
src/app/admin/operations.service.ts

@@ -80,7 +80,7 @@ export class OperationsService {
     return this.functions.operations<PayrollDetail>('ops/payroll/preview', { month });
   }
 
-  payrollOverview(month: string, storeId = 0, search = '', refresh = true): Promise<PayrollOverview> {
+  payrollOverview(month: string, storeId = 0, search = '', refresh = false): Promise<PayrollOverview> {
     return this.functions.operations<PayrollOverview>('ops/payroll/overview', { month, storeId, search, refresh });
   }
 

+ 1 - 1
src/app/admin/pages/operations-payroll.component.html

@@ -1,4 +1,4 @@
-<header class="page-header payroll-header"><div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按旧系统有效上课记录核算收益,并完成月度审核与发放登记</span></div><div class="header-actions"><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新实时数据</button><button type="button" (click)="exportExcel()" [disabled]="saving()"><lucide-icon [img]="icons.Download" [size]="17" />导出 Excel</button></div></header>
+<header class="page-header payroll-header"><div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按旧系统有效上课记录核算收益,并完成月度审核与发放登记</span></div><div class="header-actions"><button type="button" (click)="refreshLive()" [disabled]="refreshing()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="refreshing()" />{{refreshing()?'正在刷新':'刷新实时数据'}}</button><button type="button" (click)="exportExcel()" [disabled]="saving()"><lucide-icon [img]="icons.Download" [size]="17" />导出 Excel</button></div></header>
 @if(message()){<p class="notice" role="status">{{message()}}</p>} @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
 <nav class="tabs" aria-label="工资结算功能"><button type="button" [class.active]="tab()==='overview'" (click)="tab.set('overview')"><lucide-icon [img]="icons.TrendingUp" [size]="17" />收益统计</button><button type="button" [class.active]="tab()==='batches'" (click)="tab.set('batches')"><lucide-icon [img]="icons.Banknote" [size]="17" />工资批次</button><button type="button" [class.active]="tab()==='rates'" (click)="tab.set('rates')"><lucide-icon [img]="icons.Settings2" [size]="17" />计价规则</button></nav>
 @if(tab()!=='rates'){<section class="toolbar payroll-toolbar"><label><span>统计月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label><label><span>门店</span><select [ngModel]="storeId()" (ngModelChange)="storeId.set(+$event)"><option [ngValue]="0">全部门店</option>@for(store of overview()?.stores||[];track store.storeId){<option [ngValue]="store.storeId">{{store.storeName}}</option>}</select></label><label class="search-field"><span>陪练搜索</span><input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="姓名或手机号" (keyup.enter)="applyFilters()" /></label><button type="button" (click)="applyFilters()">查询</button><span class="source-chip" [class.cache]="overview()?.sourceStatus?.mode==='cache'"><i></i>{{overview()?.sourceStatus?.mode==='live'?'实时数据':'缓存数据'}} · {{overview()?.sourceStatus?.refreshedAt|date:'MM-dd HH:mm:ss'}}</span></section>}

+ 5 - 4
src/app/admin/pages/operations-payroll.component.ts

@@ -11,10 +11,11 @@ const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.g
 
 @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})
 export class OperationsPayrollComponent implements OnInit{
- 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 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 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,Settings2,TrendingUp,X};readonly coachLines=computed(()=>{const coachId=this.selectedCoachId(),source=this.detail()?.lines||this.overview()?.lines||[];return coachId?source.filter((line)=>line.coachId===coachId):source;});private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
- ngOnInit():void{void this.load();}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
- async load():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()),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);}}
- async applyFilters():Promise<void>{await this.load();}
+ 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 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,Settings2,TrendingUp,X};readonly coachLines=computed(()=>{const coachId=this.selectedCoachId(),source=this.detail()?.lines||this.overview()?.lines||[];return coachId?source.filter((line)=>line.coachId===coachId):source;});private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
+ ngOnInit():void{void this.load(true);}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
+ async load(refreshAfterLoad=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(),false),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);}if(refreshAfterLoad)void this.refreshLive();}
+ 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);}}
+ async applyFilters():Promise<void>{await this.load(true);}
  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);}}
  openCoach(row:PayrollCoachSummary):void{this.selectedCoachId.set(row.coachId);this.detail.set(null);}closeDrawer():void{this.detail.set(null);this.selectedCoachId.set(0);}
  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);}}