Selaa lähdekoodia

feat(admin): count active studying members per store

彭峰 2 viikkoa sitten
vanhempi
commit
ffbe3be84b

+ 1 - 0
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -463,6 +463,7 @@ export interface StoreSummary extends Record<string, unknown> {
   parentStoreId: number;
   parentStoreName: string;
   memberCount: number;
+  activeStudyingMemberCount?: number;
   coachCount: number;
   todayAppointments: number;
   totalAppointments: number;

+ 3 - 3
projects/xiaoshu-admin/src/app/pages/operations-stores.component.html

@@ -27,16 +27,16 @@
 
 <section class="content-card">
   <header><div><lucide-icon [img]="icons.Building2" [size]="19"/><strong>门店名单</strong></div><span>{{page()?.total||0}} 家 · {{page()?.sync?.label||'已读取当前数据'}}</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></tr></thead><tbody>
+  <div class="table-scroll"><table class="data-table"><thead><tr><th>门店</th><th>上级门店</th><th>联系方式</th><th>会员</th><th title="学习状态为正在学习,且账号状态为正常">在学正常学员</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.storeName}}</strong><small>{{row.username}} · ID {{row.userId}}</small>@if(row.sourceLabel){<small class="source-note">{{row.sourceLabel}}</small>}</td>
       <td>{{row.parentStoreName||'总店/未归属'}}</td>
       <td>{{row.contactName||'未设置联系人'}}<small>{{row.mobile||row.mobileMasked||'未留手机号'}}</small></td>
-      <td><strong>{{row.memberCount}}</strong> 人</td><td><strong>{{row.coachCount}}</strong> 人</td><td>{{row.todayAppointments}} 节</td><td>{{row.totalAppointments}} 节</td>
+      <td><strong>{{row.memberCount}}</strong> 人</td><td><strong>{{row.activeStudyingMemberCount ?? '—'}}</strong>@if(row.activeStudyingMemberCount != null){ 人}</td><td><strong>{{row.coachCount}}</strong> 人</td><td>{{row.todayAppointments}} 节</td><td>{{row.totalAppointments}} 节</td>
       <td>{{row.lastAppointmentAt?(row.lastAppointmentAt|date:'yyyy-MM-dd HH:mm'):'暂无'}}</td><td>{{row.lastLoginAt?(row.lastLoginAt|date:'yyyy-MM-dd HH:mm'):'从未登录'}}</td>
       <td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span></td>
       <td><div class="row-actions">@if(!row.readOnly){<a class="icon-action" [routerLink]="['/admin/stores',row.objectId]" aria-label="查看门店经营详情" title="经营详情"><lucide-icon [img]="icons.Eye" [size]="16"/></a>}@else{<span class="readonly-label">待同步</span>}@if(page()?.canManage&&!row.readOnly){<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>}</div></td>
-    </tr>}@empty{<tr><td colspan="11"><div class="empty">没有符合条件的门店</div></td></tr>}
+    </tr>}@empty{<tr><td colspan="12"><div class="empty">没有符合条件的门店</div></td></tr>}
   </tbody></table></div>
   <footer class="pagination"><span>第 {{page()?.page||1}} / {{pageCount()}} 页</span><div><button type="button" [disabled]="(page()?.page||1)<=1" (click)="load((page()?.page||1)-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><button type="button" [disabled]="(page()?.page||1)>=pageCount()" (click)="load((page()?.page||1)+1)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></div></footer>
 </section>

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-stores.component.scss

@@ -50,7 +50,7 @@
 .summary-grid strong { display: block; margin: 7px 0 2px; color: #21352e; font-size: 29px; line-height: 1; }
 .summary-grid small { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
 
-.data-table { min-width: 1360px; }
+.data-table { min-width: 1500px; }
 .row-actions { min-width: 122px; flex-wrap: nowrap; }
 .store-editor { width: min(640px, 100%); }
 .store-editor header p { margin: 0; color: #258b68; font-size: 11px; font-weight: 800; letter-spacing: .16em; }

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

@@ -3393,12 +3393,14 @@ async function storeRows(context,input){
     optionalLegacyMemberSnapshot(context,input),
   ]),liveItems=legacyAccounts.available?legacyAccounts.items||[]:[],liveById=new Map(liveItems.map((row)=>[number(row.userId),row])),liveIds=new Set(liveById.keys()),coachIds=new Set((legacyAccounts.coachIds||[]).map(number).filter(Boolean)),children=new Map(),addChild=(storeId,type)=>{if(!storeId||!type)return;const current=children.get(storeId)||{members:0,coaches:0};if(type==='coach')current.coaches++;else current.members++;children.set(storeId,current);},projectedMembers=await queryUsers(context,input);
   for(const member of projectedMembers)addChild(number(member.parentUserId),'member');
+  const activeStudyingCounts=new Map();
+  for(const member of filterMembers(projectedMembers,{filters:{learningStatus:'studying',status:'active'}})){const storeId=number(member.parentUserId);if(storeId)activeStudyingCounts.set(storeId,(activeStudyingCounts.get(storeId)||0)+1);}
   if(legacyAccounts.available)for(const coachId of coachIds){const account=liveById.get(coachId);if(account)addChild(number(account.parentUserId),'coach');}
   if(!legacyAccounts.available)for(const row of localChildRows){const userId=number(row.uid),storeId=number(row.sid);if(!storeId||liveIds.has(userId)||number(row.gid)===2)continue;const identity=text(row.identity_type);if(identity==='coach')addChild(storeId,'coach');else if(identity==='member'&&!projectedMembers.some((member)=>number(member.userId)===userId))addChild(storeId,'member');}
   const coachStore=new Map(localCoaches.map((row)=>[number(row.userId),number(row.parentUserId)]));for(const coachId of coachIds){const account=liveById.get(coachId);if(account&&number(account.parentUserId))coachStore.set(coachId,number(account.parentUserId));}const appointmentMap=new Map(),today=businessDate();
   for(const appointment of snapshot.appointments){const storeId=coachStore.get(number(appointment.coachId));if(!storeId||appointment.cancelled)continue;const entry=appointmentMap.get(storeId)||{todayAppointments:0,totalAppointments:0,lastAppointmentAt:''};entry.totalAppointments++;if(businessDate(appointment.startsAt)===today)entry.todayAppointments++;if(appointment.startsAt>entry.lastAppointmentAt)entry.lastAppointmentAt=appointment.startsAt;appointmentMap.set(storeId,entry);}
-  const localRows=baseRows.map((row)=>{const legacy=legacyOf(row),userId=number(row.legacyUserId||legacy.UserID),account=liveById.get(userId),mobile=text(account&&account.mobile)||storeMobileOf(row),child=children.get(userId)||{},appointments=appointmentMap.get(userId)||{},parentLegacy=legacyOf({legacyUserData:row.parentLegacy}),parentStoreId=number(account&&account.parentUserId||legacy.ParentUserID),storeName=text(account&&(account.companyName||account.username||account.nickname||account.realName))||storeNameOf(row),contactName=text(account&&account.realName||account&&account.nickname||row.nickname||legacy.HoneyName),disabled=account?number(account.state,1)===0:row.isDisabled===true;return{objectId:text(row.objectId),userId,username:text(account&&account.username||row.username),storeName,contactName:contactName===storeName?'':contactName,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentStoreId,parentStoreName:text(account&&account.parentDisplayName||row.parentRealName||row.parentUsername||parentLegacy.CompanyName||parentLegacy.HoneyName),memberCount:number(child.members),coachCount:number(child.coaches),todayAppointments:number(appointments.todayAppointments),totalAppointments:number(appointments.totalAppointments),lastAppointmentAt:text(appointments.lastAppointmentAt),registeredAt:text(account&&account.registeredAt)||registeredAtOf(row),lastLoginAt:text(account&&account.lastLoginAt)||firstDateIso(legacy.LastLoginTime,row.lastLoginAt),loginCount:number(account&&account.loginCount,legacy.LoginTimes||legacy.LoginCount),status:disabled?'disabled':'active',statusLabel:disabled?'已停用':'正常',updatedAt:dateIso(row.updatedAt),source:account?'hybrid':'parse',sourceLabel:account?'旧系统账号已关联':'新系统门店账号',readOnly:false};}),knownIds=new Set(localRows.map((row)=>row.userId));let rows=localRows.slice();
-  if(legacyAccounts.available)for(const account of liveItems){const userId=number(account.userId);if(number(account.groupId)!==2||!userId||knownIds.has(userId))continue;knownIds.add(userId);const mobile=text(account.mobile),child=children.get(userId)||{},appointments=appointmentMap.get(userId)||{},storeName=text(account.companyName||account.nickname||account.realName||account.username)||'门店 '+userId,contactName=text(account.realName),disabled=number(account.state,1)===0;rows.push({objectId:'legacy-live-store:'+userId,userId,username:text(account.username),storeName,contactName:contactName===storeName?'':contactName,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentStoreId:number(account.parentUserId),parentStoreName:text(account.parentDisplayName),memberCount:number(child.members),coachCount:number(child.coaches),todayAppointments:number(appointments.todayAppointments),totalAppointments:number(appointments.totalAppointments),lastAppointmentAt:text(appointments.lastAppointmentAt),registeredAt:text(account.registeredAt),lastLoginAt:text(account.lastLoginAt),loginCount:number(account.loginCount),status:disabled?'disabled':'active',statusLabel:disabled?'已停用':'正常',updatedAt:text(account.updatedAt),source:'legacy-live',sourceLabel:'旧系统实时门店 · 待同步',readOnly:true});}
+  const localRows=baseRows.map((row)=>{const legacy=legacyOf(row),userId=number(row.legacyUserId||legacy.UserID),account=liveById.get(userId),mobile=text(account&&account.mobile)||storeMobileOf(row),child=children.get(userId)||{},appointments=appointmentMap.get(userId)||{},parentLegacy=legacyOf({legacyUserData:row.parentLegacy}),parentStoreId=number(account&&account.parentUserId||legacy.ParentUserID),storeName=text(account&&(account.companyName||account.username||account.nickname||account.realName))||storeNameOf(row),contactName=text(account&&account.realName||account&&account.nickname||row.nickname||legacy.HoneyName),disabled=account?number(account.state,1)===0:row.isDisabled===true;return{objectId:text(row.objectId),userId,username:text(account&&account.username||row.username),storeName,contactName:contactName===storeName?'':contactName,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentStoreId,parentStoreName:text(account&&account.parentDisplayName||row.parentRealName||row.parentUsername||parentLegacy.CompanyName||parentLegacy.HoneyName),memberCount:number(child.members),activeStudyingMemberCount:activeStudyingCounts.get(userId)||0,coachCount:number(child.coaches),todayAppointments:number(appointments.todayAppointments),totalAppointments:number(appointments.totalAppointments),lastAppointmentAt:text(appointments.lastAppointmentAt),registeredAt:text(account&&account.registeredAt)||registeredAtOf(row),lastLoginAt:text(account&&account.lastLoginAt)||firstDateIso(legacy.LastLoginTime,row.lastLoginAt),loginCount:number(account&&account.loginCount,legacy.LoginTimes||legacy.LoginCount),status:disabled?'disabled':'active',statusLabel:disabled?'已停用':'正常',updatedAt:dateIso(row.updatedAt),source:account?'hybrid':'parse',sourceLabel:account?'旧系统账号已关联':'新系统门店账号',readOnly:false};}),knownIds=new Set(localRows.map((row)=>row.userId));let rows=localRows.slice();
+  if(legacyAccounts.available)for(const account of liveItems){const userId=number(account.userId);if(number(account.groupId)!==2||!userId||knownIds.has(userId))continue;knownIds.add(userId);const mobile=text(account.mobile),child=children.get(userId)||{},appointments=appointmentMap.get(userId)||{},storeName=text(account.companyName||account.nickname||account.realName||account.username)||'门店 '+userId,contactName=text(account.realName),disabled=number(account.state,1)===0;rows.push({objectId:'legacy-live-store:'+userId,userId,username:text(account.username),storeName,contactName:contactName===storeName?'':contactName,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentStoreId:number(account.parentUserId),parentStoreName:text(account.parentDisplayName),memberCount:number(child.members),activeStudyingMemberCount:activeStudyingCounts.get(userId)||0,coachCount:number(child.coaches),todayAppointments:number(appointments.todayAppointments),totalAppointments:number(appointments.totalAppointments),lastAppointmentAt:text(appointments.lastAppointmentAt),registeredAt:text(account.registeredAt),lastLoginAt:text(account.lastLoginAt),loginCount:number(account.loginCount),status:disabled?'disabled':'active',statusLabel:disabled?'已停用':'正常',updatedAt:text(account.updatedAt),source:'legacy-live',sourceLabel:'旧系统实时门店 · 待同步',readOnly:true});}
   const allRows=rows.slice(),paging=pageInput(input),status=text(paging.filters.status||'all');if(status!=='all')rows=rows.filter((row)=>row.status===status);rows=searchRows(rows,input).sort((a,b)=>b.registeredAt.localeCompare(a.registeredAt)||b.userId-a.userId);const page=pageRows(rows,input),summary={total:allRows.length,active:allRows.filter((row)=>row.status==='active').length,disabled:allRows.filter((row)=>row.status==='disabled').length,members:allRows.reduce((sum,row)=>sum+row.memberCount,0),unboundMembers:projectedMembers.filter((row)=>!number(row.parentUserId)||!knownIds.has(number(row.parentUserId))).length,coaches:allRows.reduce((sum,row)=>sum+row.coachCount,0),todayAppointments:allRows.reduce((sum,row)=>sum+row.todayAppointments,0),liveOnly:allRows.filter((row)=>row.readOnly).length},parents=localRows.map((row)=>({objectId:row.objectId,userId:row.userId,displayName:row.storeName})).filter((row)=>row.userId);
   const sourceMessages=[text(snapshot.live.message),text(legacyAccounts.message)].filter(Boolean);return{...page,summary,parents,canManage:roleOf(context)==='ops-manager',liveSourceAvailable:true,liveSourceMessage:sourceMessages.join(';')||'新数据库门店投影',legacyStoreCount:allRows.length,sync:await operationalSyncIndicator(context,input)};
 }

+ 55 - 0
scripts/tests/store-member-counts.test.mjs

@@ -0,0 +1,55 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
+
+const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
+const gateway = source.slice(source.indexOf('async function queryUsers(context,input)'));
+function functionSource(name) {
+  const start = gateway.search(new RegExp(`^(?:async )?function ${name}\\(`, 'm'));
+  assert.ok(start >= 0, `Missing ${name}`);
+  const rest = gateway.slice(start);
+  const end = rest.slice(1).search(/^(?:async )?function /m);
+  return end < 0 ? rest : rest.slice(0, end + 1);
+}
+
+for (const live of [false, true]) {
+  test(`门店在学正常人数复用会员筛选,按归属汇总且不受门店分页影响(旧门店数据:${live})`, async () => {
+    const member = (userId, parentUserId, learningStatus, status) => ({ userId, parentUserId, learningStatus, status });
+    const members = [
+      member(101, 1, 'studying', 'active'), member(102, 1, 'studying', 'active'),
+      member(103, 1, 'studying', 'disabled'), member(104, 1, 'completed', 'active'),
+      member(105, 1, 'trial', 'active'), member(106, 1, 'not_started', 'active'),
+      member(201, 2, 'studying', 'active'), member(202, 2, 'completed', 'active'),
+      member(401, 4, 'studying', 'active'), member(901, 0, 'studying', 'active'),
+    ];
+    const stores = [1, 2, 3].map((id) => ({ objectId: `store-${id}`, legacyUserId: id, username: `门店${id}` }));
+    const context = {
+      number: (value, fallback = 0) => Number(value) || fallback,
+      text: (value) => String(value ?? ''),
+      companyIdOf: () => 'company', roleOf: () => 'ops-manager',
+      legacyOf: (row) => row.legacyUserData || {}, mobileOf: () => '', maskMobile: () => '',
+      registeredAtOf: () => '', firstDateIso: () => '', dateIso: () => '', businessDate: () => '2026-09-25',
+      Psql: { query: async (sql) => sql.startsWith('SELECT u.*') ? stores : [] },
+      coachRows: async () => [], appointmentSnapshot: async () => ({ appointments: [], live: {} }),
+      optionalLegacyMemberSnapshot: async () => ({ available: live, coachIds: [], items: live ? [{ userId: 4, groupId: 2, username: '旧门店4' }] : [] }),
+      queryUsers: async () => members, operationalSyncIndicator: async () => ({}),
+      searchRows: (rows) => rows,
+    };
+    runInNewContext(['pageInput', 'pageRows', 'filterMembers', 'storeNameOf', 'storeMobileOf', 'storeRows'].map(functionSource).join('\n') + '\nthis.listStores = storeRows;', context);
+    const result = await context.listStores({}, { page: 1, pageSize: 20 });
+    const byId = new Map(result.items.map((row) => [row.userId, row]));
+    assert.equal(byId.get(1).memberCount, 6);
+    assert.equal(byId.get(1).activeStudyingMemberCount, 2);
+    assert.equal(byId.get(2).activeStudyingMemberCount, 1);
+    assert.equal(byId.get(3).activeStudyingMemberCount, 0);
+    if (live) assert.equal(byId.get(4).activeStudyingMemberCount, 1);
+    for (const row of result.items) {
+      const expected = context.filterMembers(members, { filters: { parentUserId: row.userId, status: 'active', learningStatus: 'studying' } }).length;
+      assert.equal(row.activeStudyingMemberCount, expected);
+    }
+    const paged = await context.listStores({}, { page: 2, pageSize: 1 });
+    assert.equal(paged.items.length, 1);
+    assert.equal(paged.items[0].activeStudyingMemberCount, byId.get(paged.items[0].userId).activeStudyingMemberCount);
+  });
+}