|
|
@@ -0,0 +1,362 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.OrgMember;
|
|
|
+import com.etotem.cfc.mapper.OrgMemberMapper;
|
|
|
+import org.springframework.jdbc.core.JdbcTemplate;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 组织统计大屏(5 个口径,规格文档 4.3 节)。
|
|
|
+ *
|
|
|
+ * 性能约束:所有统计先取 org_members 活跃成员 user_id 集合,再批量 IN 查询
|
|
|
+ * 由数据库聚合(GROUP BY / COUNT / SUM),禁止 per-member 循环查询。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class OrgStatsService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private OrgMemberMapper orgMemberMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private JdbcTemplate jdbcTemplate;
|
|
|
+
|
|
|
+ /** 总览卡片:成员总数/规划师数/普通用户数/家庭数/总订单/总消费(分) */
|
|
|
+ public Map<String, Object> overview(Long orgId) {
|
|
|
+ List<Long> memberIds = getActiveMemberIds(orgId);
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ if (memberIds.isEmpty()) {
|
|
|
+ result.put("memberCount", 0);
|
|
|
+ result.put("plannerCount", 0);
|
|
|
+ result.put("userCount", 0);
|
|
|
+ result.put("familyCount", 0);
|
|
|
+ result.put("totalOrders", 0);
|
|
|
+ result.put("totalConsumption", 0L);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ String in = buildIn(memberIds.size());
|
|
|
+
|
|
|
+ result.put("memberCount", memberIds.size());
|
|
|
+ // 规划师数/普通用户数:org_members 按 member_type 分组
|
|
|
+ Map<String, Integer> typeCount = jdbcTemplate.query(
|
|
|
+ "SELECT member_type, COUNT(*) AS cnt FROM org_members " +
|
|
|
+ "WHERE org_id = ? AND status = 'active' GROUP BY member_type",
|
|
|
+ rs -> {
|
|
|
+ Map<String, Integer> m = new HashMap<>();
|
|
|
+ while (rs.next()) {
|
|
|
+ m.put(rs.getString("member_type"), rs.getInt("cnt"));
|
|
|
+ }
|
|
|
+ return m;
|
|
|
+ }, orgId);
|
|
|
+ result.put("plannerCount", typeCount.getOrDefault("planner", 0));
|
|
|
+ result.put("userCount", typeCount.getOrDefault("user", 0));
|
|
|
+
|
|
|
+ // 家庭数:families.creator_id IN (memberIds) 去重
|
|
|
+ Integer familyCount = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(DISTINCT creator_id) FROM families WHERE creator_id IN (" + in + ")",
|
|
|
+ Integer.class, memberIds.toArray());
|
|
|
+ result.put("familyCount", familyCount != null ? familyCount : 0);
|
|
|
+
|
|
|
+ // 总订单/总消费:product_orders PAID/COMPLETED
|
|
|
+ Map<String, Object> orderAgg = jdbcTemplate.query(
|
|
|
+ "SELECT COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS total " +
|
|
|
+ "FROM product_orders " +
|
|
|
+ "WHERE buyer_id IN (" + in + ") AND status IN ('PAID','COMPLETED')",
|
|
|
+ rs -> {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ if (rs.next()) {
|
|
|
+ m.put("cnt", rs.getInt("cnt"));
|
|
|
+ m.put("total", rs.getLong("total"));
|
|
|
+ } else {
|
|
|
+ m.put("cnt", 0);
|
|
|
+ m.put("total", 0L);
|
|
|
+ }
|
|
|
+ return m;
|
|
|
+ }, memberIds.toArray());
|
|
|
+ result.put("totalOrders", orderAgg.get("cnt"));
|
|
|
+ result.put("totalConsumption", orderAgg.get("total"));
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 家庭维度:活跃家庭数/成员任务完成率/打卡率 */
|
|
|
+ public Map<String, Object> families(Long orgId, String startDate, String endDate) {
|
|
|
+ List<Long> memberIds = getActiveMemberIds(orgId);
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ if (memberIds.isEmpty()) {
|
|
|
+ result.put("activeFamilyCount", 0);
|
|
|
+ result.put("taskCompletionRate", 0);
|
|
|
+ result.put("checkinRate", 0);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ String in = buildIn(memberIds.size());
|
|
|
+ Date start = parseDate(startDate);
|
|
|
+ Date end = parseDate(endDate);
|
|
|
+
|
|
|
+ // 活跃家庭数:families.creator_id IN (memberIds) 且 updated_at >= 近30天
|
|
|
+ Integer activeFamily = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(DISTINCT creator_id) FROM families " +
|
|
|
+ "WHERE creator_id IN (" + in + ") AND updated_at >= ?",
|
|
|
+ Integer.class, prepend(memberIds, start));
|
|
|
+ result.put("activeFamilyCount", activeFamily != null ? activeFamily : 0);
|
|
|
+
|
|
|
+ // 任务完成率:近30天 completed 任务数 ÷ 同范围任务总数
|
|
|
+ Long completed = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM tasks " +
|
|
|
+ "WHERE status = 'completed' AND completed_at BETWEEN ? AND ?",
|
|
|
+ Long.class, start, end);
|
|
|
+ Long allTasks = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM tasks WHERE completed_at BETWEEN ? AND ?",
|
|
|
+ Long.class, start, end);
|
|
|
+ long completedOrDefault = completed != null ? completed : 0L;
|
|
|
+ long allOrDefault = allTasks != null ? allTasks : 0L;
|
|
|
+ result.put("taskCompletionRate", allOrDefault > 0 ? Math.round(completedOrDefault * 100.0 / allOrDefault) : 0);
|
|
|
+
|
|
|
+ // 打卡率:近30天有任意打卡(daily_checkin/health_checkins/emotion_checkin)的成员数 ÷ 成员总数
|
|
|
+ Integer checkinMembers = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(DISTINCT user_id) FROM (" +
|
|
|
+ " SELECT user_id FROM health_checkins WHERE created_at >= ? " +
|
|
|
+ " UNION " +
|
|
|
+ " SELECT fm.user_id FROM daily_checkin dc " +
|
|
|
+ " JOIN family_members fm ON fm.id = dc.member_id " +
|
|
|
+ " WHERE dc.created_at >= ? AND fm.user_id IN (" + in + ") " +
|
|
|
+ " UNION " +
|
|
|
+ " SELECT ec.user_id FROM emotion_checkin ec WHERE ec.created_at >= ? AND ec.user_id IS NOT NULL " +
|
|
|
+ ") t WHERE t.user_id IS NOT NULL",
|
|
|
+ Integer.class, start, start, start);
|
|
|
+ result.put("checkinRate", memberIds.size() > 0
|
|
|
+ ? Math.round((checkinMembers != null ? checkinMembers : 0) * 100.0 / memberIds.size()) : 0);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 活跃度趋势(按天分组):任务完成/打卡/报告上传 */
|
|
|
+ public List<Map<String, Object>> active(Long orgId, String startDate, String endDate) {
|
|
|
+ List<Long> memberIds = getActiveMemberIds(orgId);
|
|
|
+ if (memberIds.isEmpty()) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ String in = buildIn(memberIds.size());
|
|
|
+ Date start = parseDate(startDate);
|
|
|
+ Date end = parseDate(endDate);
|
|
|
+
|
|
|
+ // 任务完成(tasks 无直接 userId,用 creator_id 近似;按 completed_at 分组)
|
|
|
+ List<Map<String, Object>> taskDays = jdbcTemplate.query(
|
|
|
+ "SELECT DATE(completed_at) AS day, COUNT(*) AS cnt FROM tasks " +
|
|
|
+ "WHERE status = 'completed' AND completed_at BETWEEN ? AND ? " +
|
|
|
+ "GROUP BY DATE(completed_at)",
|
|
|
+ (rs, i) -> {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("day", rs.getString("day"));
|
|
|
+ m.put("taskCompleted", rs.getInt("cnt"));
|
|
|
+ return m;
|
|
|
+ }, start, end);
|
|
|
+
|
|
|
+ // 打卡(daily_checkin + health_checkins + emotion_checkin,按天分组)
|
|
|
+ List<Map<String, Object>> checkinDays = jdbcTemplate.query(
|
|
|
+ "SELECT day, SUM(cnt) AS cnt FROM (" +
|
|
|
+ " SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM health_checkins " +
|
|
|
+ " WHERE created_at BETWEEN ? AND ? GROUP BY DATE(created_at) " +
|
|
|
+ " UNION ALL " +
|
|
|
+ " SELECT DATE(dc.created_at) AS day, COUNT(*) AS cnt FROM daily_checkin dc " +
|
|
|
+ " JOIN family_members fm ON fm.id = dc.member_id " +
|
|
|
+ " WHERE fm.user_id IN (" + in + ") AND dc.created_at BETWEEN ? AND ? " +
|
|
|
+ " GROUP BY DATE(dc.created_at) " +
|
|
|
+ " UNION ALL " +
|
|
|
+ " SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM emotion_checkin " +
|
|
|
+ " WHERE created_at BETWEEN ? AND ? GROUP BY DATE(created_at) " +
|
|
|
+ ") t GROUP BY day",
|
|
|
+ (rs, i) -> {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("day", rs.getString("day"));
|
|
|
+ m.put("checkins", rs.getInt("cnt"));
|
|
|
+ return m;
|
|
|
+ }, start, end, start, end, start, end);
|
|
|
+
|
|
|
+ // 报告上传
|
|
|
+ List<Map<String, Object>> reportDays = jdbcTemplate.query(
|
|
|
+ "SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM dan_report_uploads " +
|
|
|
+ "WHERE uploader_user_id IN (" + in + ") AND created_at BETWEEN ? AND ? " +
|
|
|
+ "GROUP BY DATE(created_at)",
|
|
|
+ (rs, i) -> {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("day", rs.getString("day"));
|
|
|
+ m.put("reports", rs.getInt("cnt"));
|
|
|
+ return m;
|
|
|
+ }, memberIds.toArray(), start, end);
|
|
|
+
|
|
|
+ // 合并为按天 Map(day -> {taskCompleted, checkins, reports})
|
|
|
+ Map<String, Map<String, Object>> merged = new HashMap<>();
|
|
|
+ mergeDay(merged, taskDays, "taskCompleted");
|
|
|
+ mergeDay(merged, checkinDays, "checkins");
|
|
|
+ mergeDay(merged, reportDays, "reports");
|
|
|
+ return new ArrayList<>(merged.values());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 营收:订单金额/会员订阅/佣金,按天分组 */
|
|
|
+ public List<Map<String, Object>> revenue(Long orgId, String startDate, String endDate) {
|
|
|
+ List<Long> memberIds = getActiveMemberIds(orgId);
|
|
|
+ if (memberIds.isEmpty()) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ String in = buildIn(memberIds.size());
|
|
|
+ Date start = parseDate(startDate);
|
|
|
+ Date end = parseDate(endDate);
|
|
|
+
|
|
|
+ List<Map<String, Object>> orderDays = jdbcTemplate.query(
|
|
|
+ "SELECT DATE(paid_at) AS day, COALESCE(SUM(total_amount),0) AS total " +
|
|
|
+ "FROM product_orders " +
|
|
|
+ "WHERE buyer_id IN (" + in + ") AND status IN ('PAID','COMPLETED') " +
|
|
|
+ "AND paid_at BETWEEN ? AND ? GROUP BY DATE(paid_at)",
|
|
|
+ (rs, i) -> {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("day", rs.getString("day"));
|
|
|
+ m.put("orderAmount", rs.getLong("total"));
|
|
|
+ return m;
|
|
|
+ }, memberIds.toArray(), start, end);
|
|
|
+
|
|
|
+ List<Map<String, Object>> subDays = jdbcTemplate.query(
|
|
|
+ "SELECT DATE(paid_at) AS day, COALESCE(SUM(amount),0) AS total " +
|
|
|
+ "FROM member_subscription_order " +
|
|
|
+ "WHERE status = 'paid' AND paid_at BETWEEN ? AND ? GROUP BY DATE(paid_at)",
|
|
|
+ (rs, i) -> {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("day", rs.getString("day"));
|
|
|
+ m.put("subAmount", rs.getLong("total"));
|
|
|
+ return m;
|
|
|
+ }, start, end);
|
|
|
+
|
|
|
+ List<Map<String, Object>> commissionDays = jdbcTemplate.query(
|
|
|
+ "SELECT DATE(created_at) AS day, COALESCE(SUM(commission_amount),0) AS total " +
|
|
|
+ "FROM commission_records " +
|
|
|
+ "WHERE referrer_id IN (" + in + ") AND created_at BETWEEN ? AND ? " +
|
|
|
+ "GROUP BY DATE(created_at)",
|
|
|
+ (rs, i) -> {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("day", rs.getString("day"));
|
|
|
+ m.put("commission", rs.getLong("total"));
|
|
|
+ return m;
|
|
|
+ }, memberIds.toArray(), start, end);
|
|
|
+
|
|
|
+ Map<String, Map<String, Object>> merged = new HashMap<>();
|
|
|
+ mergeDay(merged, orderDays, "orderAmount");
|
|
|
+ mergeDay(merged, subDays, "subAmount");
|
|
|
+ mergeDay(merged, commissionDays, "commission");
|
|
|
+ return new ArrayList<>(merged.values());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 趋势图:活跃成员数/订单数/收入(分),按天分组 */
|
|
|
+ public List<Map<String, Object>> trend(Long orgId, String startDate, String endDate) {
|
|
|
+ List<Long> memberIds = getActiveMemberIds(orgId);
|
|
|
+ if (memberIds.isEmpty()) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ String in = buildIn(memberIds.size());
|
|
|
+ Date start = parseDate(startDate);
|
|
|
+ Date end = parseDate(endDate);
|
|
|
+
|
|
|
+ // 活跃成员数(当日有任一行为去重):任务完成 creator + 打卡 user + 订单 buyer + 报告 uploader
|
|
|
+ List<Map<String, Object>> activeDays = jdbcTemplate.query(
|
|
|
+ "SELECT day, COUNT(DISTINCT uid) AS cnt FROM (" +
|
|
|
+ " SELECT DATE(completed_at) AS day, creator_id AS uid FROM tasks " +
|
|
|
+ " WHERE status = 'completed' AND completed_at BETWEEN ? AND ? " +
|
|
|
+ " UNION ALL " +
|
|
|
+ " SELECT DATE(created_at) AS day, user_id AS uid FROM health_checkins " +
|
|
|
+ " WHERE created_at BETWEEN ? AND ? AND user_id IN (" + in + ") " +
|
|
|
+ " UNION ALL " +
|
|
|
+ " SELECT DATE(paid_at) AS day, buyer_id AS uid FROM product_orders " +
|
|
|
+ " WHERE paid_at BETWEEN ? AND ? AND buyer_id IN (" + in + ") " +
|
|
|
+ " UNION ALL " +
|
|
|
+ " SELECT DATE(created_at) AS day, uploader_user_id AS uid FROM dan_report_uploads " +
|
|
|
+ " WHERE created_at BETWEEN ? AND ? AND uploader_user_id IN (" + in + ") " +
|
|
|
+ ") t WHERE t.uid IS NOT NULL GROUP BY day",
|
|
|
+ (rs, i) -> {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("day", rs.getString("day"));
|
|
|
+ m.put("activeMembers", rs.getInt("cnt"));
|
|
|
+ return m;
|
|
|
+ }, start, end, start, end, start, end, start, end);
|
|
|
+
|
|
|
+ List<Map<String, Object>> orderDays = jdbcTemplate.query(
|
|
|
+ "SELECT DATE(paid_at) AS day, COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS total " +
|
|
|
+ "FROM product_orders " +
|
|
|
+ "WHERE buyer_id IN (" + in + ") AND status IN ('PAID','COMPLETED') " +
|
|
|
+ "AND paid_at BETWEEN ? AND ? GROUP BY DATE(paid_at)",
|
|
|
+ (rs, i) -> {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("day", rs.getString("day"));
|
|
|
+ m.put("orderCount", rs.getInt("cnt"));
|
|
|
+ m.put("income", rs.getLong("total"));
|
|
|
+ return m;
|
|
|
+ }, memberIds.toArray(), start, end);
|
|
|
+
|
|
|
+ Map<String, Map<String, Object>> merged = new HashMap<>();
|
|
|
+ mergeDay(merged, activeDays, "activeMembers");
|
|
|
+ mergeDay(merged, orderDays, "orderCount");
|
|
|
+ // 把 orderDays 的 income 并入同 day
|
|
|
+ for (Map<String, Object> od : orderDays) {
|
|
|
+ String day = String.valueOf(od.get("day"));
|
|
|
+ merged.computeIfAbsent(day, k -> new HashMap<>()).put("income", od.get("income"));
|
|
|
+ }
|
|
|
+ return new ArrayList<>(merged.values());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 辅助 ==========
|
|
|
+
|
|
|
+ private List<Long> getActiveMemberIds(Long orgId) {
|
|
|
+ LambdaQueryWrapper<OrgMember> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(OrgMember::getOrgId, orgId);
|
|
|
+ wrapper.eq(OrgMember::getStatus, "active");
|
|
|
+ List<OrgMember> members = orgMemberMapper.selectList(wrapper);
|
|
|
+ return members.stream().map(OrgMember::getUserId).distinct().collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ private void mergeDay(Map<String, Map<String, Object>> merged,
|
|
|
+ List<Map<String, Object>> rows, String field) {
|
|
|
+ for (Map<String, Object> row : rows) {
|
|
|
+ String day = String.valueOf(row.get("day"));
|
|
|
+ merged.computeIfAbsent(day, k -> new HashMap<>()).put(field, row.get(field));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String buildIn(int cnt) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (int i = 0; i < cnt; i++) {
|
|
|
+ if (i > 0) {
|
|
|
+ sb.append(",");
|
|
|
+ }
|
|
|
+ sb.append("?");
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private Object[] prepend(List<Long> list, Object extra) {
|
|
|
+ Object[] arr = new Object[list.size() + 1];
|
|
|
+ arr[0] = extra;
|
|
|
+ for (int i = 0; i < list.size(); i++) {
|
|
|
+ arr[i + 1] = list.get(i);
|
|
|
+ }
|
|
|
+ return arr;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date parseDate(String date) {
|
|
|
+ if (date == null || date.isEmpty()) {
|
|
|
+ // 默认近30天
|
|
|
+ return new Date(System.currentTimeMillis() - 30L * 24 * 3600 * 1000);
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
|
|
+ return sdf.parse(date.length() > 10 ? date.substring(0, 10) : date);
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new IllegalArgumentException("日期格式错误,应为 yyyy-MM-dd: " + date);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|