Przeglądaj źródła

feat(admin): sort coaches by schedule activity

彭峰 2 tygodni temu
rodzic
commit
9ea0bd882a

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-coaches.component.html

@@ -16,7 +16,7 @@
   <article><span>已停用</span><strong>{{page()?.summary?.disabled||0}}</strong><small>保留历史排课和工资</small></article>
 </section>}
 <section class="content-card">
-  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位 · 完整历史陪练口径 · {{ page()?.sync?.label || (page()?.liveSourceAvailable?'实时账号已读取':'迁移库备用数据') }} · {{page()?.payrollSourceStatus?.mode==='live'?'本月工资已实时同步':'本月工资显示缓存'}}</span></header>
+  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位 · 按未来排课和最近排课排序 · {{ page()?.sync?.label || (page()?.liveSourceAvailable?'实时账号已读取':'迁移库备用数据') }} · {{page()?.payrollSourceStatus?.mode==='live'?'本月工资已实时同步':'本月工资显示缓存'}}</span></header>
   <div class="table-scroll"><table class="data-table"><thead><tr><th>老师</th><th>所属门店</th><th>今日排课</th><th>全部排课</th><th>最近/下次排课</th><th>待完成</th><th>本月课次</th><th>本月工时</th><th>预估工资</th><th>平均评分</th><th>最近上课</th><th>状态</th><th>操作</th></tr></thead><tbody>
     @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.username?row.username+' · ':''}}ID {{row.userId}} · {{row.mobile||row.mobileMasked||'未留手机号'}}</small><small class="source-note">{{row.sourceLabel||'新系统账号'}}</small></td><td>{{row.storeName||'未绑定'}}</td><td>{{row.todayAppointments}}</td><td><strong>{{row.totalAppointments}}</strong></td><td>@if(row.nextAppointmentAt){<span class="schedule-date future">下次 {{row.nextAppointmentAt|date:'yyyy-MM-dd'}}</span>}@else if(row.lastAppointmentAt){<span class="schedule-date">最近 {{row.lastAppointmentAt|date:'yyyy-MM-dd'}}</span>}@else{暂无}</td><td>{{row.pendingAppointments}}</td><td>{{row.monthLessons}}</td><td>{{row.monthHours|number:'1.1-1'}}h</td><td class="money">¥{{row.estimatedPay|number:'1.2-2'}}</td><td>{{row.averageScore? (row.averageScore|number:'1.1-1'):'暂无'}}</td><td>{{row.lastLessonAt?(row.lastLessonAt|date:'MM-dd HH:mm'):'暂无'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span>@if(!row.availabilityConfigured&&!row.readOnly){<small class="exception">未设置可用时间</small>}</td><td><div class="row-actions"><a class="icon-action" [routerLink]="['/admin/coaches',row.objectId]" aria-label="查看陪练详情" title="查看"><lucide-icon [img]="icons.Eye" [size]="16"/></a>@if(!row.readOnly&&page()?.canManage){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑陪练" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除陪练" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}@if(row.readOnly){<span class="sync-only">详情只读</span>}</div></td></tr>}
     @empty{<tr><td colspan="13"><div class="empty">没有符合条件的陪练老师</div></td></tr>}

+ 5 - 4
scripts/deploy-admin-functions.mjs

@@ -3328,12 +3328,13 @@ async function deleteCoach(context,input) {
 }
 function businessDate(value=new Date()) { return new Date(value).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}); }
 async function appointmentCountsForBusinessDate(context,input,date=businessDate()) { const appointments=await allAppointmentRows(context,input),active=appointments.filter((row)=>!row.cancelled&&businessDate(row.startsAt)===date),byCoach=new Map();for(const row of active)if(row.coachId)byCoach.set(row.coachId,(byCoach.get(row.coachId)||0)+1);return{active,byCoach}; }
-function appointmentHistory(rows) { const byCoach=new Map(),dates=[],now=new Date().toISOString();for(const row of rows){const date=row.startsAt?businessDate(row.startsAt):'';if(/^\d{4}-\d{2}-\d{2}$/.test(date))dates.push(date);if(!row.coachId)continue;const current=byCoach.get(row.coachId)||{totalAppointments:0,lastAppointmentAt:'',nextAppointmentAt:''};current.totalAppointments+=1;if(row.startsAt>=now){if(!current.nextAppointmentAt||row.startsAt<current.nextAppointmentAt)current.nextAppointmentAt=row.startsAt;}else if(row.startsAt>current.lastAppointmentAt)current.lastAppointmentAt=row.startsAt;byCoach.set(row.coachId,current);}dates.sort();return{byCoach,totalAppointments:rows.length,dataDateFrom:dates[0]||'',dataDateTo:dates[dates.length-1]||''};}
+function appointmentHistory(rows) { const byCoach=new Map(),dates=[],now=new Date().toISOString();let totalAppointments=0;for(const row of rows){if(row.cancelled)continue;totalAppointments+=1;const date=row.startsAt?businessDate(row.startsAt):'';if(/^\d{4}-\d{2}-\d{2}$/.test(date))dates.push(date);if(!row.coachId)continue;const current=byCoach.get(row.coachId)||{totalAppointments:0,lastAppointmentAt:'',nextAppointmentAt:''};current.totalAppointments+=1;if(row.startsAt>=now&&number(row.statusCode)<30){if(!current.nextAppointmentAt||row.startsAt<current.nextAppointmentAt)current.nextAppointmentAt=row.startsAt;}else if(row.startsAt<now&&row.startsAt>current.lastAppointmentAt)current.lastAppointmentAt=row.startsAt;byCoach.set(row.coachId,current);}dates.sort();return{byCoach,totalAppointments,dataDateFrom:dates[0]||'',dataDateTo:dates[dates.length-1]||''};}
+function coachActivityCompare(a,b) { const aNext=text(a.nextAppointmentAt),bNext=text(b.nextAppointmentAt);if(Boolean(aNext)!==Boolean(bNext))return aNext?-1:1;if(aNext&&bNext){const futureOrder=aNext.localeCompare(bNext);if(futureOrder)return futureOrder;}const aLast=text(a.lastAppointmentAt),bLast=text(b.lastAppointmentAt);if(Boolean(aLast)!==Boolean(bLast))return aLast?-1:1;if(aLast&&bLast){const historyOrder=bLast.localeCompare(aLast);if(historyOrder)return historyOrder;}return text(a.displayName).localeCompare(text(b.displayName),'zh-CN')||number(a.userId)-number(b.userId);}
 function mergeAppointments(stored,liveRows) { const merged=new Map();for(const row of stored)merged.set(row.generalId?'general:'+row.generalId:'target:'+row.targetId,row);for(const row of liveRows)merged.set(row.generalId?'general:'+row.generalId:'target:'+row.targetId,row);return Array.from(merged.values()); }
 async function appointmentSnapshot(context,input,dateFrom=businessDate()) { const stored=await allAppointmentRows(context,input),legacyRefresh=input.legacyRefresh===true,live=legacyRefresh?await cachedLiveAppointmentRows(context,input,dateFrom,true):{available:true,mode:'parse',message:'新数据库排课投影',items:[],allItems:[],total:stored.length,latestAt:stored.reduce((latest,row)=>row.startsAt>latest?row.startsAt:latest,''),refreshedAt:new Date().toISOString(),cacheHit:false,stale:false},appointments=legacyRefresh?mergeAppointments(stored,live.allItems):stored;return{stored,live,appointments}; }
-function professionalCoachPageFromBase(context,input,month,base){const paging=pageInput(input),status=text(paging.filters.status||'all'),filtered=searchRows((base.rows||[]).filter((row)=>status==='all'||row.status===status),input).sort((a,b)=>a.displayName.localeCompare(b.displayName,'zh-CN')),page=pageRows(filtered,input);return{...page,summary:base.summary||{total:(base.rows||[]).length,active:(base.rows||[]).filter((row)=>row.status==='active').length,disabled:(base.rows||[]).filter((row)=>row.status==='disabled').length,monthActive:(base.rows||[]).filter((row)=>row.monthLessons>0).length,liveOnly:(base.rows||[]).filter((row)=>row.readOnly===true).length},canManage:roleOf(context)==='ops-manager',stores:base.stores||[],liveSourceAvailable:base.liveSourceAvailable===true,liveSourceMessage:text(base.liveSourceMessage),liveSourceCount:number(base.liveSourceCount),dataDateFrom:text(base.dataDateFrom),dataDateTo:text(base.dataDateTo),payrollMonth:month,payrollSourceStatus:base.payrollSourceStatus,payrollRefreshedAt:text(base.payrollRefreshedAt),sync:base.sync};}
+function professionalCoachPageFromBase(context,input,month,base){const paging=pageInput(input),status=text(paging.filters.status||'all'),filtered=searchRows((base.rows||[]).filter((row)=>status==='all'||row.status===status),input).sort(coachActivityCompare),page=pageRows(filtered,input);return{...page,summary:base.summary||{total:(base.rows||[]).length,active:(base.rows||[]).filter((row)=>row.status==='active').length,disabled:(base.rows||[]).filter((row)=>row.status==='disabled').length,monthActive:(base.rows||[]).filter((row)=>row.monthLessons>0).length,liveOnly:(base.rows||[]).filter((row)=>row.readOnly===true).length},canManage:roleOf(context)==='ops-manager',stores:base.stores||[],liveSourceAvailable:base.liveSourceAvailable===true,liveSourceMessage:text(base.liveSourceMessage),liveSourceCount:number(base.liveSourceCount),dataDateFrom:text(base.dataDateFrom),dataDateTo:text(base.dataDateTo),payrollMonth:month,payrollSourceStatus:base.payrollSourceStatus,payrollRefreshedAt:text(base.payrollRefreshedAt),sync:base.sync};}
 async function professionalCoachRows(context,input,providedSnapshot=null,providedPayroll=null) {
-  const month=/^\d{4}-\d{2}$/.test(text(input.month))?text(input.month):businessDate().slice(0,7),baseKey='coach-page-base-v5:'+month+':'+roleOf(context);if(!providedSnapshot&&!providedPayroll&&input.refresh!==true){const cached=await realtimeCacheRead(context,input,baseKey);if(cached&&cached.age<REALTIME_CACHE_TTL_MS)return professionalCoachPageFromBase(context,input,month,cached.payload);}
+  const month=/^\d{4}-\d{2}$/.test(text(input.month))?text(input.month):businessDate().slice(0,7),baseKey='coach-page-base-v6:'+month+':'+roleOf(context);if(!providedSnapshot&&!providedPayroll&&input.refresh!==true){const cached=await realtimeCacheRead(context,input,baseKey);if(cached&&cached.age<REALTIME_CACHE_TTL_MS)return professionalCoachPageFromBase(context,input,month,cached.payload);}
   const [local,snapshot,payroll,legacyAccounts]=await Promise.all([coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{...(input.filters||{}),status:'all'}},false),providedSnapshot||appointmentSnapshot(context,input,month+'-01'),providedPayroll||payrollOverviewData(context,{...input,month,storeId:0,search:'',refresh:input.refresh===true}),optionalLegacyMemberSnapshot(context,input)]),history=appointmentHistory(snapshot.appointments),today=businessDate(),todayCounts=new Map(),pendingCounts=new Map(),payrollMap=new Map(payroll.coachItems.map((row)=>[number(row.coachId),row])),accountMap=new Map((legacyAccounts.items||[]).map((row)=>[number(row.userId),row]));
   for(const row of snapshot.appointments){if(!row.coachId||row.cancelled)continue;if(businessDate(row.startsAt)===today)todayCounts.set(row.coachId,(todayCounts.get(row.coachId)||0)+1);if(row.statusCode<30&&businessDate(row.startsAt)>=today)pendingCounts.set(row.coachId,(pendingCounts.get(row.coachId)||0)+1);}
   const rows=local.map((row)=>{const pay=payrollMap.get(row.userId),account=accountMap.get(row.userId),accountName=account&&text(account.realName||account.nickname||account.username),mobile=text(account&&account.mobile)||row.mobile||row.mobileMasked,parentUserId=number(account&&account.parentUserId,row.parentUserId),status=account?(number(account.state,1)===0?'disabled':'active'):row.status;return{...row,username:text(account&&account.username)||row.username,displayName:accountName||row.displayName,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentUserId,storeName:text(account&&account.parentDisplayName)||row.storeName,status,statusLabel:status==='disabled'?'已停用':'正常',todayAppointments:todayCounts.get(row.userId)||0,pendingAppointments:pendingCounts.get(row.userId)||0,...(history.byCoach.get(row.userId)||{totalAppointments:0,lastAppointmentAt:'',nextAppointmentAt:''}),monthLessons:number(pay&&pay.lessonCount),monthHours:number(pay&&pay.totalHours),estimatedPay:number(pay&&pay.payableAmount),averageScore:number(pay&&pay.averageScore,row.averageScore),lastLessonAt:text(pay&&pay.latestLessonAt)||row.lastLessonAt,registeredAt:text(account&&account.registeredAt),hasCurrentBusiness:Boolean(number(pay&&pay.lessonCount)||todayCounts.get(row.userId)||pendingCounts.get(row.userId)),source:'hybrid',sourceLabel:account?(payroll.sourceStatus.mode==='live'?'旧账号已关联 · 实时工资':'旧账号已关联 · 缓存工资'):(payroll.sourceStatus.mode==='live'?'新系统账号 · 实时工资':'新系统账号 · 缓存工资'),readOnly:false};}),knownCoachIds=new Set(rows.map((row)=>row.userId));
@@ -3344,7 +3345,7 @@ async function professionalCoachRows(context,input,providedSnapshot=null,provide
 }
 async function professionalCoachDetail(context,input,objectId) {
   const month=/^\d{4}-\d{2}$/.test(text(input.month))?text(input.month):businessDate().slice(0,7),detailKey='coach-detail:'+objectId+':'+month+':'+roleOf(context);if(input.refresh!==true){const cached=await realtimeCacheRead(context,input,detailKey);if(cached&&cached.age<REALTIME_CACHE_TTL_MS)return cached.payload;}
-  const [payroll,snapshot,availability,legacyAccounts]=await Promise.all([payrollOverviewData(context,{...input,month,storeId:0,search:'',refresh:input.refresh===true}),appointmentSnapshot(context,input,month+'-01'),availabilityRows(context,input),optionalLegacyMemberSnapshot(context,input)]),syntheticCoachId=number((objectId.match(/:(\d+)$/)||[])[1]),selectedBase=await realtimeCacheRead(context,input,'coach-page-base-v5:'+month+':'+roleOf(context)),currentBase=selectedBase||await realtimeCacheRead(context,input,'coach-page-base-v5:'+businessDate().slice(0,7)+':'+roleOf(context));let coach=currentBase&&currentBase.payload&&Array.isArray(currentBase.payload.rows)?currentBase.payload.rows.find((row)=>row.objectId===objectId):null;
+  const [payroll,snapshot,availability,legacyAccounts]=await Promise.all([payrollOverviewData(context,{...input,month,storeId:0,search:'',refresh:input.refresh===true}),appointmentSnapshot(context,input,month+'-01'),availabilityRows(context,input),optionalLegacyMemberSnapshot(context,input)]),syntheticCoachId=number((objectId.match(/:(\d+)$/)||[])[1]),selectedBase=await realtimeCacheRead(context,input,'coach-page-base-v6:'+month+':'+roleOf(context)),currentBase=selectedBase||await realtimeCacheRead(context,input,'coach-page-base-v6:'+businessDate().slice(0,7)+':'+roleOf(context));let coach=currentBase&&currentBase.payload&&Array.isArray(currentBase.payload.rows)?currentBase.payload.rows.find((row)=>row.objectId===objectId):null;
   if(!coach&&!syntheticCoachId){const local=await coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'all'}},false);coach=local.find((row)=>row.objectId===objectId);}
   if(!coach&&syntheticCoachId){const pay=payroll.coachItems.find((row)=>row.coachId===syntheticCoachId),appointment=snapshot.appointments.find((row)=>row.coachId===syntheticCoachId),account=(legacyAccounts.items||[]).find((row)=>number(row.userId)===syntheticCoachId),mobile=text(account&&account.mobile),status=account&&number(account.state,1)===0?'disabled':'active';coach={objectId,userId:syntheticCoachId,username:text(account&&account.username),displayName:text(account&&(account.realName||account.nickname||account.username)||pay&&pay.coachName||appointment&&appointment.coachName)||'陪练 '+syntheticCoachId,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentUserId:number(account&&account.parentUserId||pay&&pay.storeId),storeName:text(account&&account.parentDisplayName||pay&&pay.storeName)||'未绑定门店',status,statusLabel:status==='disabled'?'已停用':'正常',todayAppointments:0,pendingAppointments:0,totalAppointments:0,lastAppointmentAt:'',monthLessons:number(pay&&pay.lessonCount),monthHours:number(pay&&pay.totalHours),estimatedPay:number(pay&&pay.payableAmount),averageScore:number(pay&&pay.averageScore),lastLessonAt:text(pay&&pay.latestLessonAt),availabilityConfigured:false,updatedAt:text(account&&account.updatedAt),source:'legacy-live',sourceLabel:pay?'旧系统账号 · 实时工资':'旧系统账号 · 历史授课证据',readOnly:true};}
   if(!coach)fail(404,'陪练不存在或尚未完成身份同步');

+ 2 - 0
scripts/tests/payroll-integrity.test.mjs

@@ -43,4 +43,6 @@ test('阅读写作只修改指定预约,必须有证据并保留其他学生
 test('奖励导出快照及游标不受分页期间新增记录影响',async()=>{for(let i=0;i<3;i++)await q('INSERT INTO "ReviewReward" ("objectId","company","taskId","status","amount") VALUES ($1,\'co\',$1,\'historical\',0)',['receipt-'+i]);const page=await context.reviewRewardPage('co',{exportMode:true,pageSize:2});await q(`INSERT INTO "ReviewReward" ("objectId",company,"taskId",status,amount) VALUES ('receipt-new','co','receipt-new','historical',0)`);const next=await context.reviewRewardPage('co',{exportMode:true,pageSize:2,snapshot:page.snapshot,afterId:page.nextCursor});assert.equal(page.total,3);assert.equal(next.total,3);assert.equal(next.items.length,1);assert.notEqual(next.items[0].objectId,'receipt-new');});
 test('工资表禁止直接绕过事务写入,迁移重跑不改变已发放金额',async()=>{await assert.rejects(q(`INSERT INTO "PayrollBatch" ("objectId",company,month,status) VALUES ('bypass','co','2099-01','paid')`),/业务事务/);const before=await one(`SELECT "payableAmount",status FROM "PayrollBatch" WHERE "objectId"=$1`,[batch.objectId]);await db.exec(migration);assert.deepEqual(await one(`SELECT "payableAmount",status FROM "PayrollBatch" WHERE "objectId"=$1`,[batch.objectId]),before);});
 test('历史课次门店名称跟随保存的门店编号而不是教师现门店',async()=>{const ctx=vm.createContext({Date,Psql:{one:async()=>({rows:[{objectId:'history',jsmz:30,szmdid:11}],version:'v'}),query:async()=>[{objectId:'teacher',legacyUserId:30,legacyUserData:{ParentUserID:10},realName:'老师'},{objectId:'old-store',legacyUserId:11,realName:'历史门店'},{objectId:'new-store',legacyUserId:10,realName:'现门店'}]}});vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);ctx.companyIdOf=()=> 'co';const result=await ctx.payrollCanonicalSnapshot(manager,{});assert.equal(result.items[0].storeId,11);assert.equal(result.items[0].storeName,'历史门店');});
+test('陪练默认按未来排课由近到远,再按历史排课由近到远',()=>{const rows=[{userId:1,displayName:'无记录',nextAppointmentAt:'',lastAppointmentAt:''},{userId:2,displayName:'较早历史',nextAppointmentAt:'',lastAppointmentAt:'2026-08-01T10:00:00.000Z'},{userId:3,displayName:'较晚未来',nextAppointmentAt:'2026-09-20T10:00:00.000Z',lastAppointmentAt:'2026-09-01T10:00:00.000Z'},{userId:4,displayName:'较近未来',nextAppointmentAt:'2026-09-12T10:00:00.000Z',lastAppointmentAt:'2025-01-01T10:00:00.000Z'},{userId:5,displayName:'最近历史',nextAppointmentAt:'',lastAppointmentAt:'2026-09-09T10:00:00.000Z'}];rows.sort(context.coachActivityCompare);assert.deepEqual(rows.map(row=>row.userId),[4,3,5,2,1]);});
+test('陪练活跃记录忽略已取消预约和已结束的未来异常记录',()=>{const result=context.appointmentHistory([{coachId:1,startsAt:'2099-01-01T10:00:00.000Z',statusCode:0,cancelled:true},{coachId:1,startsAt:'2099-01-02T10:00:00.000Z',statusCode:30,cancelled:false},{coachId:1,startsAt:'2099-01-03T10:00:00.000Z',statusCode:10,cancelled:false}]);assert.equal(result.totalAppointments,2);assert.equal(result.byCoach.get(1).nextAppointmentAt,'2099-01-03T10:00:00.000Z');});
 test('旧单价接口也保留修订记录且审计在同一事务中',async()=>{const req={classType:2,amount:40,durationHours:1,effectiveFrom:'2026-08-01',active:true,reason:'历史工资规则修订'};const first=(await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify(req)])).r;const v=await version();await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify({...req,amount:99})]);assert.notEqual(await version(),v);assert.equal((await one(`SELECT amount FROM "CoachPayRate" WHERE "objectId"=$1`,[first.objectId])).amount,40);await assert.rejects(q(`UPDATE "CoachPayRate" SET amount=1 WHERE "objectId"=$1`,[first.objectId]),/不可改写/);assert.equal((await one(`SELECT count(*)::int n FROM "SysLog" WHERE type2='save-legacy-rate'`)).n,2);});