Explorar o código

抽问抽答新增完成趋势和错题分析接口(适配美兰4级架构)

simonlll hai 4 semanas
pai
achega
dd42ed9ef0

+ 233 - 4
airport-admin/src/main/java/com/sundot/airport/web/controller/exam/DailyExamController.java

@@ -1,6 +1,10 @@
1 1
 package com.sundot.airport.web.controller.exam;
2 2
 
3 3
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.sundot.airport.common.core.domain.entity.SysDept;
5
+import com.sundot.airport.common.core.domain.entity.SysRole;
6
+import com.sundot.airport.common.core.domain.model.LoginUser;
7
+import com.sundot.airport.common.enums.RoleTypeEnum;
4 8
 import com.sundot.airport.common.utils.SecurityUtils;
5 9
 import com.sundot.airport.exam.adapter.DailyTaskToExamAdapter;
6 10
 import com.sundot.airport.exam.common.ClientTypeConstant;
@@ -20,6 +24,7 @@ import com.sundot.airport.exam.logic.eighteen.model.result.subresult.ItemIn18Exa
20 24
 import com.sundot.airport.exam.mapper.DailyTaskDetailMapper;
21 25
 import com.sundot.airport.exam.mapper.DailyTaskMapper;
22 26
 import com.sundot.airport.exam.service.IDailyAnswerService;
27
+import com.sundot.airport.system.mapper.SysDeptMapper;
23 28
 import org.slf4j.Logger;
24 29
 import org.slf4j.LoggerFactory;
25 30
 import org.springframework.beans.factory.annotation.Autowired;
@@ -27,11 +32,12 @@ import org.springframework.validation.annotation.Validated;
27 32
 import org.springframework.web.bind.annotation.*;
28 33
 
29 34
 import javax.annotation.Resource;
35
+import java.sql.Date;
30 36
 import java.text.SimpleDateFormat;
31
-import java.util.ArrayList;
32
-import java.util.HashMap;
33
-import java.util.List;
34
-import java.util.Map;
37
+import java.time.LocalDate;
38
+import java.time.format.DateTimeFormatter;
39
+import java.util.*;
40
+import java.util.stream.Collectors;
35 41
 
36 42
 /**
37 43
  * <b>功能名:</b>DailyExamController<br>
@@ -59,6 +65,9 @@ public class DailyExamController {
59 65
     @Autowired
60 66
     private DailyTaskToExamAdapter dailyTaskAdapter;
61 67
 
68
+    @Autowired
69
+    private SysDeptMapper sysDeptMapper;
70
+
62 71
     /**
63 72
      * <b>方法名: </b> getExamIntro <br>
64 73
      * <b>说明: </b> 查询考试基本信息(兼容接口,实际返回当日任务信息) <br>
@@ -278,4 +287,224 @@ public class DailyExamController {
278 287
             return HttpResult.error("提交答题失败:" + e.getMessage());
279 288
         }
280 289
     }
290
+
291
+    /**
292
+     * 抽问抽答完成趋势
293
+     * 美兰4级架构:STATION > 大队(BRIGADE) > 主管(MANAGER) > 班组(TEAMS)
294
+     * 站长/管理员/质检科:各大队完成趋势折线
295
+     * 大队长(经理/行政):本大队各主管完成趋势折线
296
+     * 主管:本主管室完成趋势(单条折线)
297
+     * 班组长/安检员:show=false
298
+     *
299
+     * @param timeType  时间类型:year/month/custom,默认 month
300
+     * @param startDate 自定义开始日期(yyyy-MM-dd),timeType=custom 时有效
301
+     * @param endDate   自定义结束日期(yyyy-MM-dd),timeType=custom 时有效
302
+     */
303
+    @GetMapping("/completion-trend")
304
+    public HttpResult<Map<String, Object>> getCompletionTrend(
305
+            @RequestParam(required = false, defaultValue = "month") String timeType,
306
+            @RequestParam(required = false) String startDate,
307
+            @RequestParam(required = false) String endDate) {
308
+
309
+        try {
310
+            LoginUser loginUser = SecurityUtils.getLoginUser();
311
+            List<String> roleKeys = loginUser.getUser().getRoles().stream()
312
+                    .map(SysRole::getRoleKey).collect(Collectors.toList());
313
+            Long userDeptId = loginUser.getDeptId();
314
+            Long userId = loginUser.getUserId();
315
+
316
+            boolean isStation = userId == 1L
317
+                    || roleKeys.contains(RoleTypeEnum.admin.getCode())
318
+                    || roleKeys.contains(RoleTypeEnum.test.getCode())
319
+                    || roleKeys.contains(RoleTypeEnum.zhijianke.getCode());
320
+            boolean isBrigade = !isStation && (roleKeys.contains(RoleTypeEnum.jingli.getCode())
321
+                    || roleKeys.contains(RoleTypeEnum.xingzheng.getCode()));
322
+            boolean isKezhang = !isStation && !isBrigade && roleKeys.contains(RoleTypeEnum.kezhang.getCode());
323
+
324
+            // 班组长及以下不显示此tab
325
+            if (!isStation && !isBrigade && !isKezhang) {
326
+                Map<String, Object> noShow = new HashMap<>();
327
+                noShow.put("show", false);
328
+                return HttpResult.success(noShow);
329
+            }
330
+
331
+            LocalDate[] range = resolveDateRange(timeType, startDate, endDate);
332
+            LocalDate start = range[0];
333
+            LocalDate end = range[1];
334
+            boolean byMonth = "year".equals(timeType);
335
+            List<String> xAxis = buildXAxis(start, end, byMonth);
336
+            List<Map<String, Object>> series = new ArrayList<>();
337
+
338
+            if (isStation) {
339
+                // 站长:查该站所有子部门,按大队分组
340
+                List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(userDeptId);
341
+                if (subDepts == null) subDepts = Collections.emptyList();
342
+
343
+                // 大队(站的直接下级)
344
+                List<SysDept> brigadeDepts = subDepts.stream()
345
+                        .filter(d -> userDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
346
+                        .collect(Collectors.toList());
347
+
348
+                // 构建 主管deptId -> 所属大队deptId 的映射
349
+                Map<Long, Long> managerToBrigadeId = new HashMap<>();
350
+                for (SysDept d : subDepts) {
351
+                    if ("0".equals(d.getDelFlag()) && d.getParentId() != null && !userDeptId.equals(d.getParentId())) {
352
+                        // 非直接下级,其parentId为某个大队,记录映射(主管级别)
353
+                        managerToBrigadeId.put(d.getDeptId(), d.getParentId());
354
+                    }
355
+                }
356
+
357
+                // 查全站所有任务
358
+                Set<Long> allDeptIds = new HashSet<>();
359
+                allDeptIds.add(userDeptId);
360
+                subDepts.forEach(d -> allDeptIds.add(d.getDeptId()));
361
+                List<DailyTask> allTasks = queryTasksByDeptIds(allDeptIds, start, end);
362
+
363
+                // 按大队分组生成折线
364
+                for (SysDept brigade : brigadeDepts) {
365
+                    Long brigadeId = brigade.getDeptId();
366
+                    List<DailyTask> brigadeTasks = allTasks.stream()
367
+                            .filter(t -> {
368
+                                Long managerId = t.getDtDepartmentId();
369
+                                if (managerId == null) return false;
370
+                                Long parentBrigadeId = managerToBrigadeId.get(managerId);
371
+                                return brigadeId.equals(parentBrigadeId);
372
+                            })
373
+                            .collect(Collectors.toList());
374
+                    Map<String, Object> item = new LinkedHashMap<>();
375
+                    item.put("name", brigade.getDeptName());
376
+                    item.put("deptId", brigadeId);
377
+                    item.put("data", buildSeriesData(xAxis, brigadeTasks, byMonth));
378
+                    series.add(item);
379
+                }
380
+
381
+            } else if (isBrigade) {
382
+                // 大队长:查本大队下各主管的任务
383
+                List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(userDeptId);
384
+                if (subDepts == null) subDepts = Collections.emptyList();
385
+
386
+                // 主管(大队的直接下级)
387
+                List<SysDept> managerDepts = subDepts.stream()
388
+                        .filter(d -> userDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
389
+                        .collect(Collectors.toList());
390
+
391
+                for (SysDept manager : managerDepts) {
392
+                    List<DailyTask> tasks = queryTasksByDepartmentId(manager.getDeptId(), start, end);
393
+                    Map<String, Object> item = new LinkedHashMap<>();
394
+                    item.put("name", manager.getDeptName());
395
+                    item.put("deptId", manager.getDeptId());
396
+                    item.put("data", buildSeriesData(xAxis, tasks, byMonth));
397
+                    series.add(item);
398
+                }
399
+
400
+            } else {
401
+                // 主管:本主管室所有任务,一条折线
402
+                List<DailyTask> tasks = queryTasksByDepartmentId(userDeptId, start, end);
403
+                String deptName = loginUser.getUser().getDept() != null
404
+                        ? loginUser.getUser().getDept().getDeptName() : "本主管";
405
+                Map<String, Object> item = new LinkedHashMap<>();
406
+                item.put("name", deptName);
407
+                item.put("deptId", userDeptId);
408
+                item.put("data", buildSeriesData(xAxis, tasks, byMonth));
409
+                series.add(item);
410
+            }
411
+
412
+            Map<String, Object> result = new LinkedHashMap<>();
413
+            result.put("show", true);
414
+            result.put("xAxis", xAxis);
415
+            result.put("series", series);
416
+            return HttpResult.success(result);
417
+
418
+        } catch (Exception e) {
419
+            log.error("获取抽问抽答完成趋势失败", e);
420
+            return HttpResult.error("获取完成趋势失败:" + e.getMessage());
421
+        }
422
+    }
423
+
424
+    /**
425
+     * 根据 timeType 计算起止日期
426
+     */
427
+    private LocalDate[] resolveDateRange(String timeType, String startDate, String endDate) {
428
+        LocalDate today = LocalDate.now();
429
+        LocalDate start;
430
+        LocalDate end = today;
431
+        switch (timeType == null ? "month" : timeType) {
432
+            case "year":
433
+                start = today.withDayOfYear(1);
434
+                end = today.withMonth(12).withDayOfMonth(31);
435
+                break;
436
+            case "custom":
437
+                start = (startDate != null) ? LocalDate.parse(startDate) : today.withDayOfMonth(1);
438
+                end = (endDate != null) ? LocalDate.parse(endDate) : today;
439
+                break;
440
+            default: // month
441
+                start = today.withDayOfMonth(1);
442
+                break;
443
+        }
444
+        return new LocalDate[]{start, end};
445
+    }
446
+
447
+    /**
448
+     * 生成X轴标签列表
449
+     * byMonth=true:每月一个标签(格式 yyyy-MM);false:每天一个标签(格式 yyyy-MM-dd)
450
+     */
451
+    private List<String> buildXAxis(LocalDate start, LocalDate end, boolean byMonth) {
452
+        List<String> xAxis = new ArrayList<>();
453
+        if (byMonth) {
454
+            LocalDate cursor = start.withDayOfMonth(1);
455
+            LocalDate endMonth = end.withDayOfMonth(1);
456
+            while (!cursor.isAfter(endMonth)) {
457
+                xAxis.add(cursor.format(DateTimeFormatter.ofPattern("yyyy-MM")));
458
+                cursor = cursor.plusMonths(1);
459
+            }
460
+        } else {
461
+            LocalDate cursor = start;
462
+            while (!cursor.isAfter(end)) {
463
+                xAxis.add(cursor.toString());
464
+                cursor = cursor.plusDays(1);
465
+            }
466
+        }
467
+        return xAxis;
468
+    }
469
+
470
+    /**
471
+     * 构建某条折线的数据数组,与 xAxis 对齐
472
+     */
473
+    private List<Integer> buildSeriesData(List<String> xAxis, List<DailyTask> tasks, boolean byMonth) {
474
+        SimpleDateFormat sdf = new SimpleDateFormat(byMonth ? "yyyy-MM" : "yyyy-MM-dd");
475
+        Map<String, Long> countMap = tasks.stream()
476
+                .filter(t -> "COMPLETED".equals(t.getDtStatus()) && t.getDtBusinessDate() != null)
477
+                .collect(Collectors.groupingBy(
478
+                        t -> sdf.format(t.getDtBusinessDate()),
479
+                        Collectors.counting()));
480
+        List<Integer> data = new ArrayList<>();
481
+        for (String label : xAxis) {
482
+            data.add(countMap.getOrDefault(label, 0L).intValue());
483
+        }
484
+        return data;
485
+    }
486
+
487
+    /**
488
+     * 按部门ID集合查询任务(用于站长视角:通过dtDeptId匹配所有子部门)
489
+     */
490
+    private List<DailyTask> queryTasksByDeptIds(Set<Long> deptIds, LocalDate start, LocalDate end) {
491
+        LambdaQueryWrapper<DailyTask> wrapper = new LambdaQueryWrapper<>();
492
+        wrapper.in(DailyTask::getDtDeptId, deptIds);
493
+        wrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
494
+        wrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
495
+        wrapper.eq(DailyTask::getDelStatus, 0);
496
+        return dailyTaskMapper.selectList(wrapper);
497
+    }
498
+
499
+    /**
500
+     * 按主管ID查询任务(通过dtDepartmentId匹配)
501
+     */
502
+    private List<DailyTask> queryTasksByDepartmentId(Long departmentId, LocalDate start, LocalDate end) {
503
+        LambdaQueryWrapper<DailyTask> wrapper = new LambdaQueryWrapper<>();
504
+        wrapper.eq(DailyTask::getDtDepartmentId, departmentId);
505
+        wrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
506
+        wrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
507
+        wrapper.eq(DailyTask::getDelStatus, 0);
508
+        return dailyTaskMapper.selectList(wrapper);
509
+    }
281 510
 }

+ 729 - 0
airport-admin/src/main/java/com/sundot/airport/web/controller/exam/DailyExamWrongAnalysisController.java

@@ -0,0 +1,729 @@
1
+package com.sundot.airport.web.controller.exam;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.sundot.airport.common.core.domain.entity.SysDept;
5
+import com.sundot.airport.common.core.domain.entity.SysRole;
6
+import com.sundot.airport.common.core.domain.model.LoginUser;
7
+import com.sundot.airport.common.enums.RoleTypeEnum;
8
+import com.sundot.airport.common.utils.SecurityUtils;
9
+import com.sundot.airport.exam.common.HttpResult;
10
+import com.sundot.airport.exam.common.service.QuesCatService;
11
+import com.sundot.airport.exam.domain.DailyTask;
12
+import com.sundot.airport.exam.domain.DailyTaskDetail;
13
+import com.sundot.airport.exam.domain.QuesCat;
14
+import com.sundot.airport.exam.mapper.DailyTaskDetailMapper;
15
+import com.sundot.airport.exam.mapper.DailyTaskMapper;
16
+import com.sundot.airport.system.domain.BaseCheckCategory;
17
+import com.sundot.airport.system.mapper.SysDeptMapper;
18
+import com.sundot.airport.system.service.IBaseCheckCategoryService;
19
+import org.slf4j.Logger;
20
+import org.slf4j.LoggerFactory;
21
+import org.springframework.beans.factory.annotation.Autowired;
22
+import org.springframework.web.bind.annotation.GetMapping;
23
+import org.springframework.web.bind.annotation.RequestMapping;
24
+import org.springframework.web.bind.annotation.RequestParam;
25
+import org.springframework.web.bind.annotation.RestController;
26
+
27
+import java.sql.Date;
28
+import java.text.SimpleDateFormat;
29
+import java.time.LocalDate;
30
+import java.time.format.DateTimeFormatter;
31
+import java.util.*;
32
+import java.util.stream.Collectors;
33
+
34
+/**
35
+ * 抽问抽答错题分析接口(美兰4级架构适配版)
36
+ * <p>
37
+ * 美兰组织架构:STATION(安检站)> BRIGADE(大队)> MANAGER(主管)> TEAMS(班组)
38
+ * <p>
39
+ * 接口1: GET /wrong-analysis/overview      总体问题分布(饼图,所有角色)
40
+ * 接口2: GET /wrong-analysis/radar         问题分布对比(雷达图,仅站长)
41
+ * 接口3: GET /wrong-analysis/category-detail  大类详情
42
+ *   - 站长:各大队对比表格 + 各大队趋势
43
+ *   - 大队长:各主管对比表格 + 各主管趋势
44
+ *   - 主管:本主管各小类甜甜圈 + 本主管趋势
45
+ *   - 班组长及以下:show=false
46
+ */
47
+@RestController
48
+@RequestMapping("/v1/cs/app/daily-exam/wrong-analysis")
49
+public class DailyExamWrongAnalysisController {
50
+
51
+    private static final Logger log = LoggerFactory.getLogger(DailyExamWrongAnalysisController.class);
52
+
53
+    @Autowired
54
+    private DailyTaskMapper dailyTaskMapper;
55
+
56
+    @Autowired
57
+    private DailyTaskDetailMapper dailyTaskDetailMapper;
58
+
59
+    @Autowired
60
+    private SysDeptMapper sysDeptMapper;
61
+
62
+    @Autowired
63
+    private QuesCatService quesCatService;
64
+
65
+    @Autowired
66
+    private IBaseCheckCategoryService baseCheckCategoryService;
67
+
68
+    // =====================================================================
69
+    // 接口1:总体问题分布(饼图)
70
+    // =====================================================================
71
+
72
+    /**
73
+     * 总体问题分布(按大类统计错题数)
74
+     * 站长/管理员/质检科:全站;大队长:本大队;主管:本主管室;班组长:本班组
75
+     *
76
+     * @param timeType  year/month/custom,默认 month
77
+     * @param startDate 自定义开始日期 yyyy-MM-dd
78
+     * @param endDate   自定义结束日期 yyyy-MM-dd
79
+     */
80
+    @GetMapping("/overview")
81
+    public HttpResult<Map<String, Object>> getOverview(
82
+            @RequestParam(required = false, defaultValue = "month") String timeType,
83
+            @RequestParam(required = false) String startDate,
84
+            @RequestParam(required = false) String endDate) {
85
+
86
+        try {
87
+            RoleInfo role = getRoleInfo();
88
+            LocalDate[] range = resolveDateRange(timeType, startDate, endDate);
89
+
90
+            List<DailyTaskDetail> errorDetails = getErrorDetails(role, range[0], range[1]);
91
+
92
+            Map<String, BaseCheckCategory> qcIdToLevel1 = buildQcIdToLevel1Map(errorDetails);
93
+
94
+            Map<Long, long[]> countByCategory = new LinkedHashMap<>();
95
+            Map<Long, String> categoryNames = new LinkedHashMap<>();
96
+
97
+            for (DailyTaskDetail detail : errorDetails) {
98
+                BaseCheckCategory cat = qcIdToLevel1.get(detail.getDtdModuleId());
99
+                if (cat != null) {
100
+                    countByCategory.merge(cat.getId(), new long[]{1, cat.getOrderNum() != null ? cat.getOrderNum() : 99},
101
+                            (a, b) -> new long[]{a[0] + 1, a[1]});
102
+                    categoryNames.put(cat.getId(), cat.getName());
103
+                }
104
+            }
105
+
106
+            List<Map<String, Object>> categories = countByCategory.entrySet().stream()
107
+                    .sorted(Comparator.comparingLong(e -> e.getValue()[1]))
108
+                    .map(e -> {
109
+                        Map<String, Object> item = new LinkedHashMap<>();
110
+                        item.put("id", e.getKey());
111
+                        item.put("name", categoryNames.get(e.getKey()));
112
+                        item.put("count", e.getValue()[0]);
113
+                        return item;
114
+                    })
115
+                    .collect(Collectors.toList());
116
+
117
+            Map<String, Object> result = new LinkedHashMap<>();
118
+            result.put("categories", categories);
119
+            return HttpResult.success(result);
120
+
121
+        } catch (Exception e) {
122
+            log.error("获取总体问题分布失败", e);
123
+            return HttpResult.error("获取总体问题分布失败:" + e.getMessage());
124
+        }
125
+    }
126
+
127
+    // =====================================================================
128
+    // 接口2:问题分布对比(雷达图,仅站长)
129
+    // =====================================================================
130
+
131
+    /**
132
+     * 问题分布对比雷达图(仅站长可见,各大队在各大类的错题数对比)
133
+     *
134
+     * @param timeType  year/month/custom,默认 month
135
+     * @param startDate 自定义开始日期
136
+     * @param endDate   自定义结束日期
137
+     */
138
+    @GetMapping("/radar")
139
+    public HttpResult<Map<String, Object>> getRadar(
140
+            @RequestParam(required = false, defaultValue = "month") String timeType,
141
+            @RequestParam(required = false) String startDate,
142
+            @RequestParam(required = false) String endDate) {
143
+
144
+        try {
145
+            RoleInfo role = getRoleInfo();
146
+            if (!role.isStation) {
147
+                Map<String, Object> noShow = new HashMap<>();
148
+                noShow.put("show", false);
149
+                return HttpResult.success(noShow);
150
+            }
151
+
152
+            LocalDate[] range = resolveDateRange(timeType, startDate, endDate);
153
+
154
+            // 查询所有大类,作为雷达图的维度
155
+            BaseCheckCategory level1Query = new BaseCheckCategory();
156
+            level1Query.setLevel(1);
157
+            List<BaseCheckCategory> level1Categories = baseCheckCategoryService.selectBaseCheckCategoryList(level1Query);
158
+            level1Categories.sort(Comparator.comparingInt(c -> c.getOrderNum() != null ? c.getOrderNum() : 99));
159
+
160
+            List<String> indicators = level1Categories.stream()
161
+                    .map(BaseCheckCategory::getName)
162
+                    .collect(Collectors.toList());
163
+            List<Long> indicatorIds = level1Categories.stream()
164
+                    .map(BaseCheckCategory::getId)
165
+                    .collect(Collectors.toList());
166
+
167
+            // 获取站下各大队(直接下级)
168
+            List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(role.userDeptId);
169
+            List<SysDept> brigadeDepts = subDepts == null ? Collections.emptyList() :
170
+                    subDepts.stream()
171
+                            .filter(d -> role.userDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
172
+                            .collect(Collectors.toList());
173
+
174
+            // 为每个大队构建一条雷达折线
175
+            List<Map<String, Object>> series = new ArrayList<>();
176
+            for (SysDept brigade : brigadeDepts) {
177
+                // 查该大队下所有主管的错误明细(通过dtDepartmentId匹配主管子部门)
178
+                List<DailyTaskDetail> details = getErrorDetailsBySubDepts(brigade.getDeptId(), range[0], range[1]);
179
+                Map<String, BaseCheckCategory> qcIdToLevel1 = buildQcIdToLevel1Map(details);
180
+
181
+                Map<Long, Long> countMap = details.stream()
182
+                        .filter(d -> qcIdToLevel1.get(d.getDtdModuleId()) != null)
183
+                        .collect(Collectors.groupingBy(
184
+                                d -> qcIdToLevel1.get(d.getDtdModuleId()).getId(),
185
+                                Collectors.counting()));
186
+
187
+                List<Long> data = indicatorIds.stream()
188
+                        .map(id -> countMap.getOrDefault(id, 0L))
189
+                        .collect(Collectors.toList());
190
+
191
+                Map<String, Object> seriesItem = new LinkedHashMap<>();
192
+                seriesItem.put("name", brigade.getDeptName());
193
+                seriesItem.put("deptId", brigade.getDeptId());
194
+                seriesItem.put("data", data);
195
+                series.add(seriesItem);
196
+            }
197
+
198
+            Map<String, Object> result = new LinkedHashMap<>();
199
+            result.put("show", true);
200
+            result.put("indicators", indicators);
201
+            result.put("series", series);
202
+            return HttpResult.success(result);
203
+
204
+        } catch (Exception e) {
205
+            log.error("获取问题分布对比失败", e);
206
+            return HttpResult.error("获取问题分布对比失败:" + e.getMessage());
207
+        }
208
+    }
209
+
210
+    // =====================================================================
211
+    // 接口3:大类详情(站长/大队长/主管)
212
+    // =====================================================================
213
+
214
+    /**
215
+     * 大类详情 - 点击饼图某一块后展示该大类下的小类分布及趋势
216
+     * <p>
217
+     * 站长:各大队小类对比表 + 各大队按日趋势
218
+     * 大队长:各主管小类对比表 + 各主管按日趋势
219
+     * 主管:本主管各小类甜甜圈分布 + 本主管按日趋势
220
+     * 班组长及以下:show=false
221
+     *
222
+     * @param categoryId 大类ID(base_check_category.id,level=1)
223
+     * @param timeType   year/month/custom,默认 month
224
+     * @param startDate  自定义开始日期
225
+     * @param endDate    自定义结束日期
226
+     */
227
+    @GetMapping("/category-detail")
228
+    public HttpResult<Map<String, Object>> getCategoryDetail(
229
+            @RequestParam Long categoryId,
230
+            @RequestParam(required = false, defaultValue = "month") String timeType,
231
+            @RequestParam(required = false) String startDate,
232
+            @RequestParam(required = false) String endDate) {
233
+
234
+        try {
235
+            RoleInfo role = getRoleInfo();
236
+
237
+            if (!role.isStation && !role.isBrigade && !role.isKezhang) {
238
+                Map<String, Object> noShow = new HashMap<>();
239
+                noShow.put("show", false);
240
+                return HttpResult.success(noShow);
241
+            }
242
+
243
+            LocalDate[] range = resolveDateRange(timeType, startDate, endDate);
244
+
245
+            BaseCheckCategory level1Cat = baseCheckCategoryService.selectBaseCheckCategoryById(categoryId);
246
+            if (level1Cat == null) {
247
+                return HttpResult.error("未找到该分类");
248
+            }
249
+
250
+            BaseCheckCategory level2Query = new BaseCheckCategory();
251
+            level2Query.setParentId(categoryId);
252
+            level2Query.setLevel(2);
253
+            List<BaseCheckCategory> subCategories = baseCheckCategoryService.selectBaseCheckCategoryList(level2Query);
254
+            subCategories.sort(Comparator.comparingInt(c -> c.getOrderNum() != null ? c.getOrderNum() : 99));
255
+
256
+            List<String> subCatNames = subCategories.stream()
257
+                    .map(BaseCheckCategory::getName).collect(Collectors.toList());
258
+            Map<String, String> subCatNameToQcId = buildSubCatNameToQcIdMap(subCatNames);
259
+            Map<String, String> qcIdToSubCatName = new HashMap<>();
260
+            subCatNameToQcId.forEach((name, qcId) -> { if (qcId != null) qcIdToSubCatName.put(qcId, name); });
261
+
262
+            Map<String, Object> result = new LinkedHashMap<>();
263
+            result.put("show", true);
264
+            result.put("categoryId", categoryId);
265
+            result.put("categoryName", level1Cat.getName());
266
+
267
+            boolean byMonth = "year".equals(timeType);
268
+            List<String> xAxis = buildXAxis(range[0], range[1], byMonth);
269
+            SimpleDateFormat sdf = new SimpleDateFormat(byMonth ? "yyyy-MM" : "yyyy-MM-dd");
270
+
271
+            if (role.isStation) {
272
+                // ---- 站长视角:各大队对比 ----
273
+                List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(role.userDeptId);
274
+                List<SysDept> brigadeDepts = subDepts == null ? Collections.emptyList() :
275
+                        subDepts.stream()
276
+                                .filter(d -> role.userDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
277
+                                .collect(Collectors.toList());
278
+
279
+                // 查各大队错误明细(按qcId过滤)
280
+                Map<Long, List<DailyTaskDetail>> detailsByBrigade = new LinkedHashMap<>();
281
+                for (SysDept brigade : brigadeDepts) {
282
+                    List<DailyTaskDetail> details = getErrorDetailsBySubDeptsAndQcIds(
283
+                            brigade.getDeptId(), new HashSet<>(qcIdToSubCatName.keySet()), range[0], range[1]);
284
+                    detailsByBrigade.put(brigade.getDeptId(), details);
285
+                }
286
+
287
+                // 表格数据:行=小类,列=各大队
288
+                List<Map<String, Object>> tableRows = new ArrayList<>();
289
+                for (String subCatName : subCatNames) {
290
+                    String qcId = subCatNameToQcId.get(subCatName);
291
+                    Map<String, Object> row = new LinkedHashMap<>();
292
+                    row.put("subCategory", subCatName);
293
+                    List<Long> deptCounts = new ArrayList<>();
294
+                    for (SysDept brigade : brigadeDepts) {
295
+                        List<DailyTaskDetail> details = detailsByBrigade.get(brigade.getDeptId());
296
+                        long cnt = details == null ? 0 : details.stream()
297
+                                .filter(d -> qcId != null && qcId.equals(d.getDtdModuleId()))
298
+                                .count();
299
+                        deptCounts.add(cnt);
300
+                    }
301
+                    row.put("counts", deptCounts);
302
+                    tableRows.add(row);
303
+                }
304
+
305
+                List<String> deptNames = brigadeDepts.stream()
306
+                        .map(SysDept::getDeptName).collect(Collectors.toList());
307
+
308
+                Map<String, Object> table = new LinkedHashMap<>();
309
+                table.put("depts", deptNames);
310
+                table.put("rows", tableRows);
311
+                result.put("table", table);
312
+
313
+                // 趋势数据(各大队按日/月错题数)
314
+                List<Map<String, Object>> trendSeries = new ArrayList<>();
315
+                for (SysDept brigade : brigadeDepts) {
316
+                    List<DailyTaskDetail> details = detailsByBrigade.get(brigade.getDeptId());
317
+                    List<Integer> data = buildTrendData(xAxis, details, sdf);
318
+                    Map<String, Object> s = new LinkedHashMap<>();
319
+                    s.put("name", brigade.getDeptName());
320
+                    s.put("deptId", brigade.getDeptId());
321
+                    s.put("data", data);
322
+                    trendSeries.add(s);
323
+                }
324
+                Map<String, Object> trend = new LinkedHashMap<>();
325
+                trend.put("xAxis", xAxis);
326
+                trend.put("series", trendSeries);
327
+                result.put("trend", trend);
328
+
329
+            } else if (role.isBrigade) {
330
+                // ---- 大队长视角:各主管对比 ----
331
+                List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(role.userDeptId);
332
+                List<SysDept> managerDepts = subDepts == null ? Collections.emptyList() :
333
+                        subDepts.stream()
334
+                                .filter(d -> role.userDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
335
+                                .collect(Collectors.toList());
336
+
337
+                // 查各主管错误明细(按qcId过滤)
338
+                Map<Long, List<DailyTaskDetail>> detailsByManager = new LinkedHashMap<>();
339
+                for (SysDept manager : managerDepts) {
340
+                    List<DailyTaskDetail> details = getErrorDetailsByDepartmentIdAndQcIds(
341
+                            manager.getDeptId(), new HashSet<>(qcIdToSubCatName.keySet()), range[0], range[1]);
342
+                    detailsByManager.put(manager.getDeptId(), details);
343
+                }
344
+
345
+                // 表格数据:行=小类,列=各主管
346
+                List<Map<String, Object>> tableRows = new ArrayList<>();
347
+                for (String subCatName : subCatNames) {
348
+                    String qcId = subCatNameToQcId.get(subCatName);
349
+                    Map<String, Object> row = new LinkedHashMap<>();
350
+                    row.put("subCategory", subCatName);
351
+                    List<Long> deptCounts = new ArrayList<>();
352
+                    for (SysDept manager : managerDepts) {
353
+                        List<DailyTaskDetail> details = detailsByManager.get(manager.getDeptId());
354
+                        long cnt = details == null ? 0 : details.stream()
355
+                                .filter(d -> qcId != null && qcId.equals(d.getDtdModuleId()))
356
+                                .count();
357
+                        deptCounts.add(cnt);
358
+                    }
359
+                    row.put("counts", deptCounts);
360
+                    tableRows.add(row);
361
+                }
362
+
363
+                List<String> deptNames = managerDepts.stream()
364
+                        .map(SysDept::getDeptName).collect(Collectors.toList());
365
+
366
+                Map<String, Object> table = new LinkedHashMap<>();
367
+                table.put("depts", deptNames);
368
+                table.put("rows", tableRows);
369
+                result.put("table", table);
370
+
371
+                // 趋势数据(各主管按日/月错题数)
372
+                List<Map<String, Object>> trendSeries = new ArrayList<>();
373
+                for (SysDept manager : managerDepts) {
374
+                    List<DailyTaskDetail> details = detailsByManager.get(manager.getDeptId());
375
+                    List<Integer> data = buildTrendData(xAxis, details, sdf);
376
+                    Map<String, Object> s = new LinkedHashMap<>();
377
+                    s.put("name", manager.getDeptName());
378
+                    s.put("deptId", manager.getDeptId());
379
+                    s.put("data", data);
380
+                    trendSeries.add(s);
381
+                }
382
+                Map<String, Object> trend = new LinkedHashMap<>();
383
+                trend.put("xAxis", xAxis);
384
+                trend.put("series", trendSeries);
385
+                result.put("trend", trend);
386
+
387
+            } else {
388
+                // ---- 主管视角:甜甜圈 + 趋势 ----
389
+                List<DailyTaskDetail> details = getErrorDetailsByDepartmentIdAndQcIds(
390
+                        role.userDeptId, new HashSet<>(qcIdToSubCatName.keySet()), range[0], range[1]);
391
+
392
+                Map<String, Long> qcIdCountMap = details.stream()
393
+                        .collect(Collectors.groupingBy(
394
+                                d -> d.getDtdModuleId() != null ? d.getDtdModuleId() : "",
395
+                                Collectors.counting()));
396
+
397
+                List<Map<String, Object>> donut = new ArrayList<>();
398
+                for (String subCatName : subCatNames) {
399
+                    String qcId = subCatNameToQcId.get(subCatName);
400
+                    Map<String, Object> item = new LinkedHashMap<>();
401
+                    item.put("name", subCatName);
402
+                    item.put("count", qcId != null ? qcIdCountMap.getOrDefault(qcId, 0L) : 0L);
403
+                    donut.add(item);
404
+                }
405
+                result.put("donut", donut);
406
+
407
+                String deptName = getDeptName(role.userDeptId);
408
+                List<Integer> trendData = buildTrendData(xAxis, details, sdf);
409
+                Map<String, Object> seriesItem = new LinkedHashMap<>();
410
+                seriesItem.put("name", deptName);
411
+                seriesItem.put("deptId", role.userDeptId);
412
+                seriesItem.put("data", trendData);
413
+
414
+                Map<String, Object> trend = new LinkedHashMap<>();
415
+                trend.put("xAxis", xAxis);
416
+                trend.put("series", Collections.singletonList(seriesItem));
417
+                result.put("trend", trend);
418
+            }
419
+
420
+            return HttpResult.success(result);
421
+
422
+        } catch (Exception e) {
423
+            log.error("获取大类详情失败 categoryId={}", categoryId, e);
424
+            return HttpResult.error("获取大类详情失败:" + e.getMessage());
425
+        }
426
+    }
427
+
428
+    // =====================================================================
429
+    // 私有工具方法
430
+    // =====================================================================
431
+
432
+    /**
433
+     * 获取当前用户角色信息
434
+     * 美兰角色优先级:站长 > 大队长(经理/行政) > 主管 > 班组长 > 安检员
435
+     */
436
+    private RoleInfo getRoleInfo() {
437
+        LoginUser loginUser = SecurityUtils.getLoginUser();
438
+        List<String> roleKeys = loginUser.getUser().getRoles().stream()
439
+                .map(SysRole::getRoleKey).collect(Collectors.toList());
440
+        Long userDeptId = loginUser.getDeptId();
441
+        Long userId = loginUser.getUserId();
442
+
443
+        boolean isStation = userId == 1L
444
+                || roleKeys.contains(RoleTypeEnum.admin.getCode())
445
+                || roleKeys.contains(RoleTypeEnum.test.getCode())
446
+                || roleKeys.contains(RoleTypeEnum.zhijianke.getCode());
447
+        boolean isBrigade = !isStation && (roleKeys.contains(RoleTypeEnum.jingli.getCode())
448
+                || roleKeys.contains(RoleTypeEnum.xingzheng.getCode()));
449
+        boolean isKezhang = !isStation && !isBrigade && roleKeys.contains(RoleTypeEnum.kezhang.getCode());
450
+
451
+        RoleInfo info = new RoleInfo();
452
+        info.isStation = isStation;
453
+        info.isBrigade = isBrigade;
454
+        info.isKezhang = isKezhang;
455
+        info.userDeptId = userDeptId;
456
+        return info;
457
+    }
458
+
459
+    private static class RoleInfo {
460
+        boolean isStation;
461
+        boolean isBrigade;
462
+        boolean isKezhang;
463
+        Long userDeptId;
464
+    }
465
+
466
+    /**
467
+     * 根据角色获取错误答题明细
468
+     */
469
+    private List<DailyTaskDetail> getErrorDetails(RoleInfo role, LocalDate start, LocalDate end) {
470
+        if (role.isStation) {
471
+            // 站长:站下所有子部门的任务(dtDeptId匹配)
472
+            List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(role.userDeptId);
473
+            Set<Long> allDeptIds = new HashSet<>();
474
+            allDeptIds.add(role.userDeptId);
475
+            if (subDepts != null) {
476
+                subDepts.forEach(d -> allDeptIds.add(d.getDeptId()));
477
+            }
478
+            return getErrorDetailsByDeptIds(allDeptIds, start, end);
479
+        } else if (role.isBrigade) {
480
+            // 大队长:本大队下所有子部门的任务(dtDeptId匹配)
481
+            List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(role.userDeptId);
482
+            Set<Long> allDeptIds = new HashSet<>();
483
+            allDeptIds.add(role.userDeptId);
484
+            if (subDepts != null) {
485
+                subDepts.forEach(d -> allDeptIds.add(d.getDeptId()));
486
+            }
487
+            return getErrorDetailsByDeptIds(allDeptIds, start, end);
488
+        } else if (role.isKezhang) {
489
+            // 主管:本主管室(dtDepartmentId匹配)
490
+            return getErrorDetailsByDepartmentId(role.userDeptId, start, end);
491
+        } else {
492
+            // 班组长:本班组(dtDeptId匹配)
493
+            return getErrorDetailsByDeptId(role.userDeptId, start, end);
494
+        }
495
+    }
496
+
497
+    /**
498
+     * 按 dt_dept_id 集合查询错误明细
499
+     */
500
+    private List<DailyTaskDetail> getErrorDetailsByDeptIds(Set<Long> deptIds, LocalDate start, LocalDate end) {
501
+        if (deptIds == null || deptIds.isEmpty()) return Collections.emptyList();
502
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
503
+        taskWrapper.in(DailyTask::getDtDeptId, deptIds);
504
+        taskWrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
505
+        taskWrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
506
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
507
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
508
+        return getErrorDetailsFromTasks(tasks);
509
+    }
510
+
511
+    /**
512
+     * 按 dt_department_id(主管级别)查询错误明细
513
+     */
514
+    private List<DailyTaskDetail> getErrorDetailsByDepartmentId(Long departmentId, LocalDate start, LocalDate end) {
515
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
516
+        taskWrapper.eq(DailyTask::getDtDepartmentId, departmentId);
517
+        taskWrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
518
+        taskWrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
519
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
520
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
521
+        return getErrorDetailsFromTasks(tasks);
522
+    }
523
+
524
+    /**
525
+     * 按 dt_department_id + 指定 qcId 集合查询错误明细
526
+     */
527
+    private List<DailyTaskDetail> getErrorDetailsByDepartmentIdAndQcIds(
528
+            Long departmentId, Set<String> qcIds, LocalDate start, LocalDate end) {
529
+        List<DailyTaskDetail> all = getErrorDetailsByDepartmentId(departmentId, start, end);
530
+        if (qcIds == null || qcIds.isEmpty()) return all;
531
+        return all.stream()
532
+                .filter(d -> qcIds.contains(d.getDtdModuleId()))
533
+                .collect(Collectors.toList());
534
+    }
535
+
536
+    /**
537
+     * 按 dt_dept_id(班组级别)查询错误明细
538
+     */
539
+    private List<DailyTaskDetail> getErrorDetailsByDeptId(Long deptId, LocalDate start, LocalDate end) {
540
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
541
+        taskWrapper.eq(DailyTask::getDtDeptId, deptId);
542
+        taskWrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
543
+        taskWrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
544
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
545
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
546
+        return getErrorDetailsFromTasks(tasks);
547
+    }
548
+
549
+    /**
550
+     * 按上级部门ID(大队)查询其下所有主管的错误明细
551
+     * 通过查询大队的直接下级主管,再按 dtDepartmentId 匹配
552
+     */
553
+    private List<DailyTaskDetail> getErrorDetailsBySubDepts(Long parentDeptId, LocalDate start, LocalDate end) {
554
+        List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(parentDeptId);
555
+        if (subDepts == null || subDepts.isEmpty()) return Collections.emptyList();
556
+        Set<Long> managerIds = subDepts.stream()
557
+                .filter(d -> parentDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
558
+                .map(SysDept::getDeptId)
559
+                .collect(Collectors.toSet());
560
+        if (managerIds.isEmpty()) return Collections.emptyList();
561
+
562
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
563
+        taskWrapper.in(DailyTask::getDtDepartmentId, managerIds);
564
+        taskWrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
565
+        taskWrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
566
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
567
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
568
+        return getErrorDetailsFromTasks(tasks);
569
+    }
570
+
571
+    /**
572
+     * 按上级部门ID(大队)查询其下所有主管的错误明细,并按 qcId 过滤
573
+     */
574
+    private List<DailyTaskDetail> getErrorDetailsBySubDeptsAndQcIds(
575
+            Long parentDeptId, Set<String> qcIds, LocalDate start, LocalDate end) {
576
+        List<DailyTaskDetail> all = getErrorDetailsBySubDepts(parentDeptId, start, end);
577
+        if (qcIds == null || qcIds.isEmpty()) return all;
578
+        return all.stream()
579
+                .filter(d -> qcIds.contains(d.getDtdModuleId()))
580
+                .collect(Collectors.toList());
581
+    }
582
+
583
+    /**
584
+     * 从任务列表中查询错误答题明细(dtdIsCorrect=false)
585
+     */
586
+    private List<DailyTaskDetail> getErrorDetailsFromTasks(List<DailyTask> tasks) {
587
+        if (tasks == null || tasks.isEmpty()) return Collections.emptyList();
588
+        Set<String> taskIds = tasks.stream().map(DailyTask::getDtId).collect(Collectors.toSet());
589
+
590
+        LambdaQueryWrapper<DailyTaskDetail> detailWrapper = new LambdaQueryWrapper<>();
591
+        detailWrapper.in(DailyTaskDetail::getDtdTaskId, taskIds);
592
+        detailWrapper.eq(DailyTaskDetail::getDtdIsCorrect, false);
593
+        detailWrapper.eq(DailyTaskDetail::getDelStatus, 0);
594
+        return dailyTaskDetailMapper.selectList(detailWrapper);
595
+    }
596
+
597
+    /**
598
+     * 构建 qcId -> level1 BaseCheckCategory 的映射(带缓存,避免重复查询)
599
+     */
600
+    private Map<String, BaseCheckCategory> buildQcIdToLevel1Map(List<DailyTaskDetail> details) {
601
+        Map<String, BaseCheckCategory> result = new HashMap<>();
602
+        Map<String, BaseCheckCategory> qcNameToLevel1Cache = new HashMap<>();
603
+
604
+        for (DailyTaskDetail detail : details) {
605
+            String qcId = detail.getDtdModuleId();
606
+            if (qcId == null || qcId.trim().isEmpty() || result.containsKey(qcId)) continue;
607
+
608
+            LambdaQueryWrapper<QuesCat> catWrapper = new LambdaQueryWrapper<>();
609
+            catWrapper.eq(QuesCat::getQcId, qcId);
610
+            catWrapper.eq(QuesCat::getDelStatus, 0);
611
+            QuesCat quesCat = quesCatService.getOne(catWrapper);
612
+
613
+            if (quesCat == null) {
614
+                result.put(qcId, null);
615
+                continue;
616
+            }
617
+
618
+            String qcName = quesCat.getQcName();
619
+            if (qcNameToLevel1Cache.containsKey(qcName)) {
620
+                result.put(qcId, qcNameToLevel1Cache.get(qcName));
621
+            } else {
622
+                BaseCheckCategory level1 = baseCheckCategoryService.selectLevel1ByLevel2Name(qcName);
623
+                qcNameToLevel1Cache.put(qcName, level1);
624
+                result.put(qcId, level1);
625
+            }
626
+        }
627
+        return result;
628
+    }
629
+
630
+    /**
631
+     * 构建小类名称 -> qcId 的映射
632
+     */
633
+    private Map<String, String> buildSubCatNameToQcIdMap(List<String> subCatNames) {
634
+        Map<String, String> result = new HashMap<>();
635
+        for (String name : subCatNames) {
636
+            LambdaQueryWrapper<QuesCat> wrapper = new LambdaQueryWrapper<>();
637
+            wrapper.eq(QuesCat::getQcName, name);
638
+            wrapper.eq(QuesCat::getDelStatus, 0);
639
+            wrapper.last("LIMIT 1");
640
+            QuesCat cat = quesCatService.getOne(wrapper);
641
+            result.put(name, cat != null ? cat.getQcId() : null);
642
+        }
643
+        return result;
644
+    }
645
+
646
+    /**
647
+     * 构建折线趋势数据,与 xAxis 对齐
648
+     */
649
+    private List<Integer> buildTrendData(List<String> xAxis, List<DailyTaskDetail> details, SimpleDateFormat sdf) {
650
+        if (details == null || details.isEmpty()) {
651
+            return xAxis.stream().map(x -> 0).collect(Collectors.toList());
652
+        }
653
+        Set<String> taskIds = details.stream().map(DailyTaskDetail::getDtdTaskId).collect(Collectors.toSet());
654
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
655
+        taskWrapper.in(DailyTask::getDtId, taskIds);
656
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
657
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
658
+        Map<String, String> taskIdToDate = tasks.stream()
659
+                .filter(t -> t.getDtBusinessDate() != null)
660
+                .collect(Collectors.toMap(DailyTask::getDtId, t -> sdf.format(t.getDtBusinessDate()), (a, b) -> a));
661
+
662
+        Map<String, Long> countByDate = details.stream()
663
+                .filter(d -> taskIdToDate.containsKey(d.getDtdTaskId()))
664
+                .collect(Collectors.groupingBy(d -> taskIdToDate.get(d.getDtdTaskId()), Collectors.counting()));
665
+
666
+        return xAxis.stream()
667
+                .map(label -> countByDate.getOrDefault(label, 0L).intValue())
668
+                .collect(Collectors.toList());
669
+    }
670
+
671
+    /**
672
+     * 根据时间类型计算起止日期
673
+     */
674
+    private LocalDate[] resolveDateRange(String timeType, String startDate, String endDate) {
675
+        LocalDate today = LocalDate.now();
676
+        LocalDate start;
677
+        LocalDate end = today;
678
+        switch (timeType == null ? "month" : timeType) {
679
+            case "year":
680
+                start = today.withDayOfYear(1);
681
+                end = today.withMonth(12).withDayOfMonth(31);
682
+                break;
683
+            case "custom":
684
+                start = (startDate != null) ? LocalDate.parse(startDate) : today.withDayOfMonth(1);
685
+                end = (endDate != null) ? LocalDate.parse(endDate) : today;
686
+                break;
687
+            default: // month
688
+                start = today.withDayOfMonth(1);
689
+                break;
690
+        }
691
+        return new LocalDate[]{start, end};
692
+    }
693
+
694
+    /**
695
+     * 生成 X 轴标签列表
696
+     */
697
+    private List<String> buildXAxis(LocalDate start, LocalDate end, boolean byMonth) {
698
+        List<String> xAxis = new ArrayList<>();
699
+        if (byMonth) {
700
+            LocalDate cursor = start.withDayOfMonth(1);
701
+            LocalDate endMonth = end.withDayOfMonth(1);
702
+            while (!cursor.isAfter(endMonth)) {
703
+                xAxis.add(cursor.format(DateTimeFormatter.ofPattern("yyyy-MM")));
704
+                cursor = cursor.plusMonths(1);
705
+            }
706
+        } else {
707
+            LocalDate cursor = start;
708
+            while (!cursor.isAfter(end)) {
709
+                xAxis.add(cursor.toString());
710
+                cursor = cursor.plusDays(1);
711
+            }
712
+        }
713
+        return xAxis;
714
+    }
715
+
716
+    /**
717
+     * 获取部门名称
718
+     */
719
+    private String getDeptName(Long deptId) {
720
+        try {
721
+            LoginUser loginUser = SecurityUtils.getLoginUser();
722
+            if (loginUser.getUser().getDept() != null) {
723
+                return loginUser.getUser().getDept().getDeptName();
724
+            }
725
+        } catch (Exception ignored) {
726
+        }
727
+        return "本主管";
728
+    }
729
+}