dashboard.service.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import type { UserDto } from '../../auth/services/auth.service.js';
  2. import {
  3. filterGroupsByDataScope,
  4. resolveDataScope,
  5. type DataScopeRequest,
  6. type ResolvedDataScope,
  7. } from '../../../../shared/auth/data-scope.service.js';
  8. import {
  9. buildCommunityDistrictIndex,
  10. parseShanghaiDistrict,
  11. } from '../../../../shared/auth/dashboard-scope.util.js';
  12. import { listGroupChats, type GroupChatDto } from '../../qiwe/services/groups.service.js';
  13. import { listCommunities } from '../../qiwe/services/community.service.js';
  14. import { listStores, type StoreDto } from '../../qiwe/services/organization.service.js';
  15. export interface DashboardStatsDto {
  16. totalGroups: number;
  17. totalMembers: number;
  18. activeGroupRate: number;
  19. avgHealthScore: number;
  20. complianceRate: number;
  21. riskEventCount: number;
  22. pendingWorkOrders: number;
  23. intentLeadCount: number;
  24. conversionRate: number;
  25. }
  26. export interface DashboardScopeGroupOption {
  27. roomId: string;
  28. roomName: string;
  29. district: string;
  30. }
  31. export interface DashboardOverviewDto {
  32. stats: DashboardStatsDto;
  33. activityDistribution: { high: number; medium: number; low: number; inactive: number };
  34. lifecycleDistribution: Array<{ phase: string; label: string; count: number }>;
  35. healthGradeDistribution: Record<string, number>;
  36. storeComparison: Array<{ storeName: string; groupCount: number }>;
  37. districtComparison: Array<{ district: string; groupCount: number }>;
  38. topGroups: GroupChatDto[];
  39. recentRiskEvents: [];
  40. stores: StoreDto[];
  41. /** 上海区级列表(二级) */
  42. districts: string[];
  43. /** 按区分组的群列表(三级) */
  44. groupsByDistrict: Record<string, DashboardScopeGroupOption[]>;
  45. scope: ResolvedDataScope;
  46. viewer: Pick<UserDto, 'id' | 'name' | 'role' | 'storeId' | 'storeName' | 'regionCode'>;
  47. }
  48. function computeStats(groups: GroupChatDto[]): DashboardStatsDto {
  49. if (groups.length === 0) {
  50. return {
  51. totalGroups: 0,
  52. totalMembers: 0,
  53. activeGroupRate: 0,
  54. avgHealthScore: 0,
  55. complianceRate: 0,
  56. riskEventCount: 0,
  57. pendingWorkOrders: 0,
  58. intentLeadCount: 0,
  59. conversionRate: 0,
  60. };
  61. }
  62. const totalMembers = groups.reduce((sum, g) => sum + g.memberCount, 0);
  63. const activeCount = groups.filter((g) => g.activityLevel === 'high' || g.activityLevel === 'medium').length;
  64. const avgHealth = groups.reduce((sum, g) => sum + g.healthScore, 0) / groups.length;
  65. const documented = groups.filter((g) => g.hasDocument).length;
  66. return {
  67. totalGroups: groups.length,
  68. totalMembers,
  69. activeGroupRate: Math.round((activeCount / groups.length) * 100),
  70. avgHealthScore: Math.round(avgHealth),
  71. complianceRate: Math.round((documented / groups.length) * 100),
  72. riskEventCount: 0,
  73. pendingWorkOrders: 0,
  74. intentLeadCount: 0,
  75. conversionRate: 0,
  76. };
  77. }
  78. function buildActivityDistribution(groups: GroupChatDto[]) {
  79. return {
  80. high: groups.filter((g) => g.activityLevel === 'high').length,
  81. medium: groups.filter((g) => g.activityLevel === 'medium').length,
  82. low: groups.filter((g) => g.activityLevel === 'low').length,
  83. inactive: groups.filter((g) => g.activityLevel === 'inactive').length,
  84. };
  85. }
  86. function buildStoreComparison(groups: GroupChatDto[]) {
  87. const storeMap = new Map<string, number>();
  88. for (const group of groups) {
  89. const name = group.storeName || '未绑定门店';
  90. storeMap.set(name, (storeMap.get(name) || 0) + 1);
  91. }
  92. return Array.from(storeMap.entries()).map(([storeName, groupCount]) => ({
  93. storeName,
  94. groupCount,
  95. }));
  96. }
  97. function buildDistrictComparison(
  98. groups: GroupChatDto[],
  99. communityIndex: ReturnType<typeof buildCommunityDistrictIndex>,
  100. ) {
  101. const map = new Map<string, number>();
  102. for (const group of groups) {
  103. let district = '未分区';
  104. if (group.communityId) {
  105. district = communityIndex.byCommunityId.get(group.communityId) || '未分区';
  106. } else if (group.communityName) {
  107. district = parseShanghaiDistrict(group.communityName) || '未分区';
  108. }
  109. map.set(district, (map.get(district) || 0) + 1);
  110. }
  111. return Array.from(map.entries())
  112. .map(([district, groupCount]) => ({ district, groupCount }))
  113. .sort((a, b) => b.groupCount - a.groupCount);
  114. }
  115. function buildGroupsByDistrict(
  116. groups: GroupChatDto[],
  117. communityIndex: ReturnType<typeof buildCommunityDistrictIndex>,
  118. ): Record<string, DashboardScopeGroupOption[]> {
  119. const map = new Map<string, DashboardScopeGroupOption[]>();
  120. for (const g of groups) {
  121. let district = '未分区';
  122. if (g.communityId) {
  123. district = communityIndex.byCommunityId.get(g.communityId) || '未分区';
  124. }
  125. const list = map.get(district) || [];
  126. list.push({ roomId: g.roomId, roomName: g.roomName || g.roomId, district });
  127. map.set(district, list);
  128. }
  129. for (const [key, list] of map) {
  130. list.sort((a, b) => a.roomName.localeCompare(b.roomName, 'zh-CN'));
  131. map.set(key, list);
  132. }
  133. return Object.fromEntries(map);
  134. }
  135. function buildLifecycleDistribution(groups: GroupChatDto[]) {
  136. const labels: Record<string, string> = {
  137. pre_handover: '交房前',
  138. near_handover: '临交房',
  139. post_handover: '已交房',
  140. legacy: '老小区',
  141. };
  142. const phases = ['pre_handover', 'near_handover', 'post_handover', 'legacy'];
  143. return phases.map((phase) => ({
  144. phase,
  145. label: labels[phase] || phase,
  146. count: groups.filter((g) => g.lifecyclePhase === phase).length,
  147. }));
  148. }
  149. function buildHealthGradeDistribution(groups: GroupChatDto[]) {
  150. const grades = ['S', 'A', 'B', 'C'];
  151. const result: Record<string, number> = {};
  152. for (const grade of grades) {
  153. result[grade] = groups.filter((g) => g.healthGrade === grade).length;
  154. }
  155. return result;
  156. }
  157. export async function getDashboardOverview(
  158. user: UserDto,
  159. scopeRequest: DataScopeRequest = {},
  160. ): Promise<DashboardOverviewDto> {
  161. const [allGroups, stores, communities, scope] = await Promise.all([
  162. listGroupChats(),
  163. listStores(),
  164. listCommunities(),
  165. resolveDataScope(user, scopeRequest),
  166. ]);
  167. const communityIndex = buildCommunityDistrictIndex(communities);
  168. const cityScoped = filterGroupsByDataScope(allGroups, {
  169. ...scope,
  170. level: scope.level === 'district' || scope.level === 'group' ? 'city' : scope.level,
  171. district: undefined,
  172. roomId: undefined,
  173. }, communityIndex);
  174. const groups = filterGroupsByDataScope(allGroups, scope, communityIndex);
  175. const topGroups = [...groups]
  176. .sort((a, b) => b.messageCountToday - a.messageCountToday)
  177. .slice(0, 10);
  178. const groupsByDistrict = buildGroupsByDistrict(cityScoped, communityIndex);
  179. const districts = [
  180. ...communityIndex.districts,
  181. ...(groupsByDistrict['未分区']?.length ? ['未分区'] : []),
  182. ].filter((d, i, arr) => arr.indexOf(d) === i);
  183. return {
  184. stats: computeStats(groups),
  185. activityDistribution: buildActivityDistribution(groups),
  186. lifecycleDistribution: buildLifecycleDistribution(groups),
  187. healthGradeDistribution: buildHealthGradeDistribution(groups),
  188. storeComparison: buildStoreComparison(scope.level === 'global' ? allGroups : groups),
  189. districtComparison: buildDistrictComparison(
  190. scope.level === 'city' ? cityScoped : groups,
  191. communityIndex,
  192. ),
  193. topGroups,
  194. recentRiskEvents: [],
  195. stores,
  196. districts,
  197. groupsByDistrict,
  198. scope: {
  199. ...scope,
  200. label: scope.label || resolveScopeLabel(scope),
  201. },
  202. viewer: {
  203. id: user.id,
  204. name: user.name,
  205. role: user.role,
  206. storeId: user.storeId,
  207. storeName: user.storeName,
  208. regionCode: user.regionCode,
  209. },
  210. };
  211. }
  212. function resolveScopeLabel(scope: ResolvedDataScope): string {
  213. if (scope.label) return scope.label;
  214. if (scope.level === 'city') return '全上海';
  215. if (scope.level === 'district' && scope.district) return scope.district;
  216. if (scope.level === 'group') return '单个客户群';
  217. if (scope.level === 'global') return '全部门店汇总';
  218. if (scope.storeName) return scope.storeName;
  219. return '数据范围';
  220. }