Переглянути джерело

PC端:抽问抽答完成趋势对比+错题分析接口(美兰)

1. DailyExamComparisonController:调整返回结构
   - 改为返回当前周期时间序列趋势数据(同completion-trend)
   - 每条series附加yoyRate(同比)和chainRatio(环比)字段
   - 适配4级架构:STATION→各大队;BRIGADE→各主管

2. 新增DailyExamWrongAnalysisPcController:
   - /wrong-analysis/pc-overview:支持scopeType/scopeId统计范围参数
   - /wrong-analysis/pc-radar:支持scopeType/scopeId,按范围展示不同粒度series
   - STATION→各大队;BRIGADE→各主管;MANAGER/USER→单条
simonlll 2 тижнів тому
батько
коміт
c21fb70745

+ 175 - 170
airport-admin/src/main/java/com/sundot/airport/web/controller/exam/DailyExamComparisonController.java

@@ -21,17 +21,21 @@ import org.springframework.web.bind.annotation.RestController;
21 21
 import java.math.BigDecimal;
22 22
 import java.math.RoundingMode;
23 23
 import java.sql.Date;
24
+import java.text.SimpleDateFormat;
24 25
 import java.time.LocalDate;
26
+import java.time.format.DateTimeFormatter;
25 27
 import java.util.*;
26 28
 import java.util.stream.Collectors;
27 29
 
28 30
 /**
29
- * 抽问抽答完成趋势对比接口(美兰4级架构)
30
- * STATION > 大队(BRIGADE) > 主管(MANAGER) > 班组
31
- * 始终返回多期聚合对比数据:
32
- *   YEAR:[去年, 今年] 含同比(yoyRate)
33
- *   QUARTER:[去年同季度, 上季度, 本季度] 含同比+环比(chainRatio)
34
- *   MONTH:[去年同月, 上月, 本月] 含同比+环比
31
+ * 抽问抽答完成趋势对比接口(PC管理端,美兰4级架构)
32
+ * STATION(安检站)> BRIGADE(大队)> MANAGER(主管)> 班组
33
+ *
34
+ * 返回当前周期的时间序列趋势数据(同 /completion-trend),
35
+ * 同时在每条 series 上附加 yoyRate(同比)和 chainRatio(环比)。
36
+ *
37
+ * yoyRate  = (本期总数 - 去年同期总数) / 去年同期总数 × 100
38
+ * chainRatio = (本期总数 - 上期总数) / 上期总数 × 100(仅 QUARTER/MONTH)
35 39
  */
36 40
 @RestController
37 41
 @RequestMapping("/v1/cs/app/daily-exam")
@@ -79,7 +83,6 @@ public class DailyExamComparisonController {
79 83
                     || roleKeys.contains(RoleTypeEnum.xingzheng.getCode()));
80 84
             boolean isManager = !isStation && !isBrigade && roleKeys.contains(RoleTypeEnum.kezhang.getCode());
81 85
 
82
-            // 班组长及以下不显示(未传 scopeType 且无管理权限)
83 86
             if (scopeType == null && !isStation && !isBrigade && !isManager) {
84 87
                 Map<String, Object> noShow = new HashMap<>();
85 88
                 noShow.put("show", false);
@@ -90,16 +93,24 @@ public class DailyExamComparisonController {
90 93
             int curQuarter = (quarter != null) ? quarter : ((LocalDate.now().getMonthValue() - 1) / 3 + 1);
91 94
             int curMonth = (month != null) ? month : LocalDate.now().getMonthValue();
92 95
 
93
-            List<LocalDate[]> periods = buildPeriods(dateRangeQueryType, curYear, curQuarter, curMonth);
94
-            List<String> xAxis = buildXAxisLabels(dateRangeQueryType, curYear, curQuarter, curMonth);
96
+            // 当前周期
97
+            LocalDate[] currentRange = resolveDateRange(dateRangeQueryType, curYear, curQuarter, curMonth);
98
+            // 去年同期(用于计算同比)
99
+            LocalDate[] yoyRange = resolveDateRange(dateRangeQueryType, curYear - 1, curQuarter, curMonth);
100
+            // 上一期(用于计算环比,仅 QUARTER/MONTH)
101
+            LocalDate[] prevRange = buildPrevRange(dateRangeQueryType, curYear, curQuarter, curMonth);
102
+
103
+            boolean byMonth = "YEAR".equals(dateRangeQueryType);
104
+            List<String> xAxis = buildXAxis(currentRange[0], currentRange[1], byMonth);
105
+
106
+            List<Map<String, Object>> series = buildComparisonSeries(
107
+                    scopeType, scopeId, xAxis, currentRange, yoyRange, prevRange,
108
+                    byMonth, isStation, isBrigade, isManager, userDeptId, loginUser);
95 109
 
96 110
             Map<String, Object> result = new LinkedHashMap<>();
97 111
             result.put("show", true);
98 112
             result.put("xAxis", xAxis);
99
-            result.put("series", buildComparisonSeries(
100
-                    scopeType, scopeId, periods, dateRangeQueryType,
101
-                    isStation, isBrigade, isManager, userDeptId, loginUser));
102
-
113
+            result.put("series", series);
103 114
             return HttpResult.success(result);
104 115
 
105 116
         } catch (Exception e) {
@@ -109,134 +120,84 @@ public class DailyExamComparisonController {
109 120
     }
110 121
 
111 122
     /**
112
-     * 构建各期日期范围列表
113
-     * YEAR:[去年, 今年]
114
-     * QUARTER:[去年同季度, 上季度, 本季度]
115
-     * MONTH:[去年同月, 上月, 本月]
123
+     * 计算上一期日期范围(QUARTER/MONTH);YEAR 返回 null(无环比)
116 124
      */
117
-    private List<LocalDate[]> buildPeriods(String type, int year, int quarter, int month) {
118
-        List<LocalDate[]> periods = new ArrayList<>();
119
-        switch (type == null ? "MONTH" : type) {
120
-            case "YEAR":
121
-                periods.add(new LocalDate[]{LocalDate.of(year - 1, 1, 1), LocalDate.of(year - 1, 12, 31)});
122
-                periods.add(new LocalDate[]{LocalDate.of(year, 1, 1), LocalDate.of(year, 12, 31)});
123
-                break;
124
-            case "QUARTER":
125
-                periods.add(resolveDateRange("QUARTER", year - 1, quarter, null));
126
-                int prevQ = quarter - 1;
127
-                int prevQYear = year;
128
-                if (prevQ < 1) { prevQ = 4; prevQYear = year - 1; }
129
-                periods.add(resolveDateRange("QUARTER", prevQYear, prevQ, null));
130
-                periods.add(resolveDateRange("QUARTER", year, quarter, null));
131
-                break;
132
-            case "MONTH":
133
-            default:
134
-                periods.add(resolveDateRange("MONTH", year - 1, null, month));
135
-                int prevM = month - 1;
136
-                int prevMYear = year;
137
-                if (prevM < 1) { prevM = 12; prevMYear = year - 1; }
138
-                periods.add(resolveDateRange("MONTH", prevMYear, null, prevM));
139
-                periods.add(resolveDateRange("MONTH", year, null, month));
140
-                break;
125
+    private LocalDate[] buildPrevRange(String type, int year, int quarter, int month) {
126
+        if ("YEAR".equals(type)) return null;
127
+        if ("QUARTER".equals(type)) {
128
+            int prevQ = quarter - 1;
129
+            int prevYear = year;
130
+            if (prevQ < 1) { prevQ = 4; prevYear = year - 1; }
131
+            return resolveDateRange("QUARTER", prevYear, prevQ, month);
141 132
         }
142
-        return periods;
133
+        // MONTH
134
+        int prevM = month - 1;
135
+        int prevYear = year;
136
+        if (prevM < 1) { prevM = 12; prevYear = year - 1; }
137
+        return resolveDateRange("MONTH", prevYear, quarter, prevM);
143 138
     }
144 139
 
145 140
     /**
146
-     * 构建 X 轴中文标签列表
147
-     */
148
-    private List<String> buildXAxisLabels(String type, int year, int quarter, int month) {
149
-        List<String> labels = new ArrayList<>();
150
-        switch (type == null ? "MONTH" : type) {
151
-            case "YEAR":
152
-                labels.add((year - 1) + "年");
153
-                labels.add(year + "年");
154
-                break;
155
-            case "QUARTER":
156
-                labels.add((year - 1) + "年第" + quarter + "季度");
157
-                int prevQ = quarter - 1;
158
-                int prevQYear = year;
159
-                if (prevQ < 1) { prevQ = 4; prevQYear = year - 1; }
160
-                labels.add(prevQYear + "年第" + prevQ + "季度");
161
-                labels.add(year + "年第" + quarter + "季度");
162
-                break;
163
-            case "MONTH":
164
-            default:
165
-                labels.add((year - 1) + "年" + month + "月");
166
-                int prevM = month - 1;
167
-                int prevMYear = year;
168
-                if (prevM < 1) { prevM = 12; prevMYear = year - 1; }
169
-                labels.add(prevMYear + "年" + prevM + "月");
170
-                labels.add(year + "年" + month + "月");
171
-                break;
172
-        }
173
-        return labels;
174
-    }
175
-
176
-    /**
177
-     * 构建多期对比 series 列表(适配美兰4级架构)
178
-     * STATION:各大队各一条 series + 全站汇总
179
-     * BRIGADE:该大队各主管各一条 series
180
-     * MANAGER/USER:单条 series
141
+     * 构建 series 列表:每条 series 含当前周期趋势数据 + yoyRate + chainRatio
181 142
      */
182 143
     private List<Map<String, Object>> buildComparisonSeries(
183
-            String scopeType, Long scopeId, List<LocalDate[]> periods, String dateRangeQueryType,
184
-            boolean isStation, boolean isBrigade, boolean isManager, Long userDeptId, LoginUser loginUser) {
185
-
186
-        boolean isYearType = "YEAR".equals(dateRangeQueryType);
144
+            String scopeType, Long scopeId,
145
+            List<String> xAxis, LocalDate[] currentRange, LocalDate[] yoyRange, LocalDate[] prevRange,
146
+            boolean byMonth, boolean isStation, boolean isBrigade, boolean isManager,
147
+            Long userDeptId, LoginUser loginUser) {
187 148
 
188 149
         if ("MANAGER".equals(scopeType) && scopeId != null) {
189
-            String name = "主管" + scopeId;
190
-            List<Integer> data = new ArrayList<>();
191
-            for (LocalDate[] p : periods) {
192
-                List<DailyTask> tasks = queryTasksByDepartmentId(scopeId, p[0], p[1]);
193
-                if (name.startsWith("主管")) {
194
-                    name = tasks.stream().map(DailyTask::getDtDepartmentName)
195
-                            .filter(Objects::nonNull).findFirst().orElse(name);
196
-                }
197
-                data.add(countCompleted(tasks));
198
-            }
199
-            return Collections.singletonList(buildSeriesItem(name, scopeId, null, data, isYearType));
150
+            List<DailyTask> current = queryTasksByDepartmentId(scopeId, currentRange[0], currentRange[1]);
151
+            String name = current.stream().map(DailyTask::getDtDepartmentName)
152
+                    .filter(Objects::nonNull).findFirst().orElse("主管" + scopeId);
153
+            int yoyCount = countCompleted(queryTasksByDepartmentId(scopeId, yoyRange[0], yoyRange[1]));
154
+            Integer prevCount = prevRange != null
155
+                    ? countCompleted(queryTasksByDepartmentId(scopeId, prevRange[0], prevRange[1])) : null;
156
+            return Collections.singletonList(
157
+                    buildSeriesItem(name, scopeId, null, xAxis, current, byMonth, yoyCount, prevCount));
200 158
 
201 159
         } else if ("USER".equals(scopeType) && scopeId != null) {
202
-            String name = "用户" + scopeId;
203
-            List<Integer> data = new ArrayList<>();
204
-            for (LocalDate[] p : periods) {
205
-                List<DailyTask> tasks = queryTasksByUserId(scopeId, p[0], p[1]);
206
-                if (name.startsWith("用户")) {
207
-                    name = tasks.stream().map(DailyTask::getDtUserName)
208
-                            .filter(Objects::nonNull).findFirst().orElse(name);
209
-                }
210
-                data.add(countCompleted(tasks));
211
-            }
212
-            return Collections.singletonList(buildSeriesItem(name, null, scopeId, data, isYearType));
160
+            List<DailyTask> current = queryTasksByUserId(scopeId, currentRange[0], currentRange[1]);
161
+            String name = current.stream().map(DailyTask::getDtUserName)
162
+                    .filter(Objects::nonNull).findFirst().orElse("用户" + scopeId);
163
+            int yoyCount = countCompleted(queryTasksByUserId(scopeId, yoyRange[0], yoyRange[1]));
164
+            Integer prevCount = prevRange != null
165
+                    ? countCompleted(queryTasksByUserId(scopeId, prevRange[0], prevRange[1])) : null;
166
+            return Collections.singletonList(
167
+                    buildSeriesItem(name, null, scopeId, xAxis, current, byMonth, yoyCount, prevCount));
213 168
 
214 169
         } else if ("BRIGADE".equals(scopeType) && scopeId != null) {
215
-            return buildBrigadeSeries(scopeId, periods, isYearType);
170
+            return buildBrigadeSeries(scopeId, xAxis, currentRange, yoyRange, prevRange, byMonth);
216 171
 
217 172
         } else {
218 173
             Long stationDeptId = ("STATION".equals(scopeType) && scopeId != null) ? scopeId : userDeptId;
219 174
             if (isStation || "STATION".equals(scopeType)) {
220
-                return buildStationSeries(stationDeptId, periods, isYearType);
175
+                return buildStationSeries(stationDeptId, xAxis, currentRange, yoyRange, prevRange, byMonth);
221 176
             } else if (isBrigade) {
222
-                return buildBrigadeSeries(userDeptId, periods, isYearType);
177
+                return buildBrigadeSeries(userDeptId, xAxis, currentRange, yoyRange, prevRange, byMonth);
223 178
             } else {
224 179
                 // 主管:单条 series
225 180
                 String deptName = loginUser.getUser().getDept() != null
226 181
                         ? loginUser.getUser().getDept().getDeptName() : "本主管";
227
-                List<Integer> data = new ArrayList<>();
228
-                for (LocalDate[] p : periods) {
229
-                    data.add(countCompleted(queryTasksByDepartmentId(userDeptId, p[0], p[1])));
230
-                }
231
-                return Collections.singletonList(buildSeriesItem(deptName, userDeptId, null, data, isYearType));
182
+                List<DailyTask> current = queryTasksByDepartmentId(userDeptId, currentRange[0], currentRange[1]);
183
+                int yoyCount = countCompleted(queryTasksByDepartmentId(userDeptId, yoyRange[0], yoyRange[1]));
184
+                Integer prevCount = prevRange != null
185
+                        ? countCompleted(queryTasksByDepartmentId(userDeptId, prevRange[0], prevRange[1])) : null;
186
+                return Collections.singletonList(
187
+                        buildSeriesItem(deptName, userDeptId, null, xAxis, current, byMonth, yoyCount, prevCount));
232 188
             }
233 189
         }
234 190
     }
235 191
 
236 192
     /**
237
-     * 站长视角:按大队分组,各期聚合 + 全站汇总
193
+     * 站长视角:按大队分组,各大队各一条 series + 全站汇总
238 194
      */
239
-    private List<Map<String, Object>> buildStationSeries(Long stationDeptId, List<LocalDate[]> periods, boolean isYearType) {
195
+    private List<Map<String, Object>> buildStationSeries(Long stationDeptId,
196
+                                                          List<String> xAxis,
197
+                                                          LocalDate[] currentRange,
198
+                                                          LocalDate[] yoyRange,
199
+                                                          LocalDate[] prevRange,
200
+                                                          boolean byMonth) {
240 201
         List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(stationDeptId);
241 202
         if (subDepts == null) subDepts = Collections.emptyList();
242 203
 
@@ -245,51 +206,42 @@ public class DailyExamComparisonController {
245 206
                 .filter(d -> stationDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
246 207
                 .collect(Collectors.toList());
247 208
 
248
-        // 主管deptId -> 所属大队deptId
249
-        Map<Long, Long> managerToBrigadeId = new HashMap<>();
250
-        for (SysDept d : subDepts) {
251
-            if ("0".equals(d.getDelFlag()) && d.getParentId() != null && !stationDeptId.equals(d.getParentId())) {
252
-                managerToBrigadeId.put(d.getDeptId(), d.getParentId());
253
-            }
254
-        }
255
-
256
-        Set<Long> allDeptIds = new HashSet<>();
257
-        allDeptIds.add(stationDeptId);
258
-        subDepts.forEach(d -> allDeptIds.add(d.getDeptId()));
259
-
260
-        List<List<DailyTask>> periodTasks = new ArrayList<>();
261
-        for (LocalDate[] p : periods) {
262
-            periodTasks.add(queryTasksByDeptIds(allDeptIds, p[0], p[1]));
263
-        }
264
-
265 209
         List<Map<String, Object>> series = new ArrayList<>();
266
-        List<Integer> totalData = new ArrayList<>();
267
-        for (int i = 0; i < periods.size(); i++) totalData.add(0);
210
+        List<DailyTask> stationCurrentAll = new ArrayList<>();
211
+        int stationYoyTotal = 0;
212
+        int stationPrevTotal = 0;
268 213
 
269 214
         for (SysDept brigade : brigadeDepts) {
270
-            Long brigadeId = brigade.getDeptId();
271
-            List<Integer> data = new ArrayList<>();
272
-            for (int i = 0; i < periodTasks.size(); i++) {
273
-                int count = countCompleted(periodTasks.get(i).stream()
274
-                        .filter(t -> {
275
-                            Long managerId = t.getDtDepartmentId();
276
-                            if (managerId == null) return false;
277
-                            return brigadeId.equals(managerToBrigadeId.get(managerId));
278
-                        })
279
-                        .collect(Collectors.toList()));
280
-                data.add(count);
281
-                totalData.set(i, totalData.get(i) + count);
282
-            }
283
-            series.add(buildSeriesItem(brigade.getDeptName(), brigadeId, null, data, isYearType));
215
+            List<DailyTask> current = queryTasksByBrigadeId(brigade.getDeptId(), currentRange[0], currentRange[1]);
216
+            int yoyCount = countCompletedByBrigadeId(brigade.getDeptId(), yoyRange[0], yoyRange[1]);
217
+            int prevCount = prevRange != null
218
+                    ? countCompletedByBrigadeId(brigade.getDeptId(), prevRange[0], prevRange[1]) : 0;
219
+
220
+            stationCurrentAll.addAll(current);
221
+            stationYoyTotal += yoyCount;
222
+            stationPrevTotal += prevCount;
223
+
224
+            series.add(buildSeriesItem(brigade.getDeptName(), brigade.getDeptId(), null,
225
+                    xAxis, current, byMonth, yoyCount, prevRange != null ? prevCount : null));
284 226
         }
285
-        series.add(buildSeriesItem("全站", null, null, totalData, isYearType));
227
+
228
+        // 全站汇总 series
229
+        int finalYoyTotal = stationYoyTotal;
230
+        Integer finalPrevTotal = prevRange != null ? stationPrevTotal : null;
231
+        series.add(buildSeriesItem("全站", null, null, xAxis, stationCurrentAll, byMonth,
232
+                finalYoyTotal, finalPrevTotal));
286 233
         return series;
287 234
     }
288 235
 
289 236
     /**
290
-     * 大队视角:按主管分组,各期聚合
237
+     * 大队视角:按主管分组,各主管各一条 series
291 238
      */
292
-    private List<Map<String, Object>> buildBrigadeSeries(Long brigadeId, List<LocalDate[]> periods, boolean isYearType) {
239
+    private List<Map<String, Object>> buildBrigadeSeries(Long brigadeId,
240
+                                                          List<String> xAxis,
241
+                                                          LocalDate[] currentRange,
242
+                                                          LocalDate[] yoyRange,
243
+                                                          LocalDate[] prevRange,
244
+                                                          boolean byMonth) {
293 245
         List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(brigadeId);
294 246
         if (subDepts == null) subDepts = Collections.emptyList();
295 247
 
@@ -299,40 +251,76 @@ public class DailyExamComparisonController {
299 251
 
300 252
         List<Map<String, Object>> series = new ArrayList<>();
301 253
         for (SysDept manager : managerDepts) {
302
-            List<Integer> data = new ArrayList<>();
303
-            for (LocalDate[] p : periods) {
304
-                data.add(countCompleted(queryTasksByDepartmentId(manager.getDeptId(), p[0], p[1])));
305
-            }
306
-            series.add(buildSeriesItem(manager.getDeptName(), manager.getDeptId(), null, data, isYearType));
254
+            List<DailyTask> current = queryTasksByDepartmentId(manager.getDeptId(), currentRange[0], currentRange[1]);
255
+            int yoyCount = countCompleted(queryTasksByDepartmentId(manager.getDeptId(), yoyRange[0], yoyRange[1]));
256
+            Integer prevCount = prevRange != null
257
+                    ? countCompleted(queryTasksByDepartmentId(manager.getDeptId(), prevRange[0], prevRange[1])) : null;
258
+            series.add(buildSeriesItem(manager.getDeptName(), manager.getDeptId(), null,
259
+                    xAxis, current, byMonth, yoyCount, prevCount));
307 260
         }
308 261
         return series;
309 262
     }
310 263
 
311 264
     /**
312
-     * 构建单条 series map,含同比/环比
313
-     * data[0]=去年同期,data[1]=上期(仅QUARTER/MONTH),data[last]=本期
265
+     * 构建单条 series:趋势数据来自当前周期,yoyRate/chainRatio 由聚合总数计算
314 266
      */
315 267
     private Map<String, Object> buildSeriesItem(String name, Long deptId, Long userId,
316
-                                                 List<Integer> data, boolean isYearType) {
268
+                                                 List<String> xAxis, List<DailyTask> currentTasks,
269
+                                                 boolean byMonth, int yoyBaseCount, Integer prevCount) {
270
+        List<Integer> data = buildSeriesData(xAxis, currentTasks, byMonth);
271
+        int currentTotal = data.stream().mapToInt(Integer::intValue).sum();
272
+
317 273
         Map<String, Object> s = new LinkedHashMap<>();
318 274
         s.put("name", name);
319 275
         if (deptId != null) s.put("deptId", deptId);
320 276
         if (userId != null) s.put("userId", userId);
321 277
         s.put("data", data);
322
-        int current = data.get(data.size() - 1);
323
-        // 同比:本期 vs 去年同期(data[0])
324
-        s.put("yoyRate", calcRate(current, data.get(0)));
325
-        // 环比:本期 vs 上一期(data[last-1]),仅 QUARTER/MONTH 有效
326
-        if (!isYearType && data.size() >= 3) {
327
-            s.put("chainRatio", calcRate(current, data.get(data.size() - 2)));
278
+        s.put("yoyRate", calcRate(currentTotal, yoyBaseCount));
279
+        s.put("chainRatio", prevCount != null ? calcRate(currentTotal, prevCount) : null);
280
+        return s;
281
+    }
282
+
283
+    /**
284
+     * 将任务列表按时间维度汇聚,对齐到 xAxis
285
+     */
286
+    private List<Integer> buildSeriesData(List<String> xAxis, List<DailyTask> tasks, boolean byMonth) {
287
+        SimpleDateFormat sdf = new SimpleDateFormat(byMonth ? "yyyy-MM" : "yyyy-MM-dd");
288
+        Map<String, Long> countMap = tasks.stream()
289
+                .filter(t -> "COMPLETED".equals(t.getDtStatus()) && t.getDtBusinessDate() != null)
290
+                .collect(Collectors.groupingBy(
291
+                        t -> sdf.format(t.getDtBusinessDate()),
292
+                        Collectors.counting()));
293
+        List<Integer> result = new ArrayList<>();
294
+        for (String label : xAxis) {
295
+            result.add(countMap.getOrDefault(label, 0L).intValue());
296
+        }
297
+        return result;
298
+    }
299
+
300
+    /**
301
+     * 生成 X 轴标签:YEAR→每月(yyyy-MM);QUARTER/MONTH→每天(yyyy-MM-dd)
302
+     */
303
+    private List<String> buildXAxis(LocalDate start, LocalDate end, boolean byMonth) {
304
+        List<String> xAxis = new ArrayList<>();
305
+        if (byMonth) {
306
+            LocalDate cursor = start.withDayOfMonth(1);
307
+            LocalDate endMonth = end.withDayOfMonth(1);
308
+            while (!cursor.isAfter(endMonth)) {
309
+                xAxis.add(cursor.format(DateTimeFormatter.ofPattern("yyyy-MM")));
310
+                cursor = cursor.plusMonths(1);
311
+            }
328 312
         } else {
329
-            s.put("chainRatio", null);
313
+            LocalDate cursor = start;
314
+            while (!cursor.isAfter(end)) {
315
+                xAxis.add(cursor.toString());
316
+                cursor = cursor.plusDays(1);
317
+            }
330 318
         }
331
-        return s;
319
+        return xAxis;
332 320
     }
333 321
 
334 322
     /**
335
-     * 计算变化率百分比,base=0 时返回 null
323
+     * 计算变化率百分比base=0 时返回 null
336 324
      */
337 325
     private BigDecimal calcRate(int current, int base) {
338 326
         if (base == 0) return null;
@@ -341,16 +329,33 @@ public class DailyExamComparisonController {
341 329
                 .divide(BigDecimal.valueOf(base), 2, RoundingMode.HALF_UP);
342 330
     }
343 331
 
344
-    /**
345
-     * 统计 COMPLETED 任务数
346
-     */
347 332
     private int countCompleted(List<DailyTask> tasks) {
348 333
         return (int) tasks.stream().filter(t -> "COMPLETED".equals(t.getDtStatus())).count();
349 334
     }
350 335
 
351 336
     /**
352
-     * 根据 type/year/quarter/month 计算起止日期
337
+     * 查询大队下所有主管的任务(通过大队子部门的 dtDepartmentId)
353 338
      */
339
+    private List<DailyTask> queryTasksByBrigadeId(Long brigadeId, LocalDate start, LocalDate end) {
340
+        List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(brigadeId);
341
+        if (subDepts == null) subDepts = Collections.emptyList();
342
+        Set<Long> managerIds = subDepts.stream()
343
+                .filter(d -> brigadeId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
344
+                .map(SysDept::getDeptId)
345
+                .collect(Collectors.toSet());
346
+        if (managerIds.isEmpty()) return Collections.emptyList();
347
+        LambdaQueryWrapper<DailyTask> wrapper = new LambdaQueryWrapper<>();
348
+        wrapper.in(DailyTask::getDtDepartmentId, managerIds);
349
+        wrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
350
+        wrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
351
+        wrapper.eq(DailyTask::getDelStatus, 0);
352
+        return dailyTaskMapper.selectList(wrapper);
353
+    }
354
+
355
+    private int countCompletedByBrigadeId(Long brigadeId, LocalDate start, LocalDate end) {
356
+        return countCompleted(queryTasksByBrigadeId(brigadeId, start, end));
357
+    }
358
+
354 359
     private LocalDate[] resolveDateRange(String type, Integer year, Integer quarter, Integer month) {
355 360
         LocalDate today = LocalDate.now();
356 361
         int y = (year != null) ? year : today.getYear();

+ 542 - 0
airport-admin/src/main/java/com/sundot/airport/web/controller/exam/DailyExamWrongAnalysisPcController.java

@@ -0,0 +1,542 @@
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.time.LocalDate;
29
+import java.util.*;
30
+import java.util.stream.Collectors;
31
+
32
+/**
33
+ * 抽问抽答错题分析 PC管理端接口(美兰4级架构)
34
+ * 美兰组织架构:STATION(安检站)> BRIGADE(大队)> MANAGER(主管)> 班组
35
+ *
36
+ * 接口1: GET /wrong-analysis/pc-overview  总体问题分布(饼图),支持 scopeType/scopeId
37
+ * 接口2: GET /wrong-analysis/pc-radar     问题分布对比(雷达图),支持 scopeType/scopeId
38
+ *
39
+ * scopeType 说明:
40
+ *   STATION  - 全站(scopeId=安检站deptId,可选)
41
+ *   BRIGADE  - 大队(scopeId=大队deptId)
42
+ *   MANAGER  - 主管(scopeId=主管deptId)
43
+ *   USER     - 个人(scopeId=userId)
44
+ *   不传     - 按登录用户角色自动判断(与 app 端 /wrong-analysis 相同逻辑)
45
+ *
46
+ * pc-radar scopeType 对应 series 规则:
47
+ *   STATION → 各大队各一条 series
48
+ *   BRIGADE → 该大队下各主管各一条 series
49
+ *   MANAGER → 单条主管 series
50
+ *   USER    → 单条用户 series
51
+ *   不传    → 站长:各大队 series;其他角色:show=false
52
+ */
53
+@RestController
54
+@RequestMapping("/v1/cs/app/daily-exam/wrong-analysis")
55
+public class DailyExamWrongAnalysisPcController {
56
+
57
+    private static final Logger log = LoggerFactory.getLogger(DailyExamWrongAnalysisPcController.class);
58
+
59
+    @Autowired
60
+    private DailyTaskMapper dailyTaskMapper;
61
+
62
+    @Autowired
63
+    private DailyTaskDetailMapper dailyTaskDetailMapper;
64
+
65
+    @Autowired
66
+    private SysDeptMapper sysDeptMapper;
67
+
68
+    @Autowired
69
+    private QuesCatService quesCatService;
70
+
71
+    @Autowired
72
+    private IBaseCheckCategoryService baseCheckCategoryService;
73
+
74
+    // =====================================================================
75
+    // 接口1:总体问题分布(饼图)PC端
76
+    // =====================================================================
77
+
78
+    /**
79
+     * 总体问题分布(按大类统计错题数)
80
+     *
81
+     * @param scopeType 统计范围:STATION/BRIGADE/MANAGER/USER,不传则按角色自动判断
82
+     * @param scopeId   范围ID
83
+     */
84
+    @GetMapping("/pc-overview")
85
+    public HttpResult<Map<String, Object>> getPcOverview(
86
+            @RequestParam(required = false, defaultValue = "MONTH") String dateRangeQueryType,
87
+            @RequestParam(required = false) Integer year,
88
+            @RequestParam(required = false) Integer quarter,
89
+            @RequestParam(required = false) Integer month,
90
+            @RequestParam(required = false) String scopeType,
91
+            @RequestParam(required = false) Long scopeId) {
92
+
93
+        try {
94
+            LocalDate[] range = resolveDateRange(dateRangeQueryType, year, quarter, month);
95
+            List<DailyTaskDetail> errorDetails = getErrorDetailsByScope(scopeType, scopeId, range[0], range[1]);
96
+
97
+            Map<String, BaseCheckCategory> qcIdToLevel1 = buildQcIdToLevel1Map(errorDetails);
98
+
99
+            Map<Long, long[]> countByCategory = new LinkedHashMap<>();
100
+            Map<Long, String> categoryNames = new LinkedHashMap<>();
101
+
102
+            for (DailyTaskDetail detail : errorDetails) {
103
+                BaseCheckCategory cat = qcIdToLevel1.get(detail.getDtdModuleId());
104
+                if (cat != null) {
105
+                    countByCategory.merge(cat.getId(),
106
+                            new long[]{1, cat.getOrderNum() != null ? cat.getOrderNum() : 99},
107
+                            (a, b) -> new long[]{a[0] + 1, a[1]});
108
+                    categoryNames.put(cat.getId(), cat.getName());
109
+                }
110
+            }
111
+
112
+            List<Map<String, Object>> categories = countByCategory.entrySet().stream()
113
+                    .sorted(Comparator.comparingLong(e -> e.getValue()[1]))
114
+                    .map(e -> {
115
+                        Map<String, Object> item = new LinkedHashMap<>();
116
+                        item.put("id", e.getKey());
117
+                        item.put("name", categoryNames.get(e.getKey()));
118
+                        item.put("count", e.getValue()[0]);
119
+                        return item;
120
+                    })
121
+                    .collect(Collectors.toList());
122
+
123
+            Map<String, Object> result = new LinkedHashMap<>();
124
+            result.put("categories", categories);
125
+            return HttpResult.success(result);
126
+
127
+        } catch (Exception e) {
128
+            log.error("获取总体问题分布失败(PC)", e);
129
+            return HttpResult.error("获取总体问题分布失败:" + e.getMessage());
130
+        }
131
+    }
132
+
133
+    // =====================================================================
134
+    // 接口2:问题分布对比(雷达图)PC端
135
+    // =====================================================================
136
+
137
+    /**
138
+     * 问题分布对比雷达图
139
+     *
140
+     * @param scopeType 统计范围:STATION/BRIGADE/MANAGER/USER,不传则按角色自动判断
141
+     * @param scopeId   范围ID
142
+     */
143
+    @GetMapping("/pc-radar")
144
+    public HttpResult<Map<String, Object>> getPcRadar(
145
+            @RequestParam(required = false, defaultValue = "MONTH") String dateRangeQueryType,
146
+            @RequestParam(required = false) Integer year,
147
+            @RequestParam(required = false) Integer quarter,
148
+            @RequestParam(required = false) Integer month,
149
+            @RequestParam(required = false) String scopeType,
150
+            @RequestParam(required = false) Long scopeId) {
151
+
152
+        try {
153
+            LocalDate[] range = resolveDateRange(dateRangeQueryType, year, quarter, month);
154
+
155
+            // 查询所有大类作为雷达维度
156
+            BaseCheckCategory level1Query = new BaseCheckCategory();
157
+            level1Query.setLevel(1);
158
+            List<BaseCheckCategory> level1Categories = baseCheckCategoryService.selectBaseCheckCategoryList(level1Query);
159
+            level1Categories.sort(Comparator.comparingInt(c -> c.getOrderNum() != null ? c.getOrderNum() : 99));
160
+
161
+            List<String> indicators = level1Categories.stream()
162
+                    .map(BaseCheckCategory::getName).collect(Collectors.toList());
163
+            List<Long> indicatorIds = level1Categories.stream()
164
+                    .map(BaseCheckCategory::getId).collect(Collectors.toList());
165
+
166
+            List<Map<String, Object>> series;
167
+
168
+            if ("BRIGADE".equals(scopeType) && scopeId != null) {
169
+                // 大队下各主管各一条 series
170
+                series = buildRadarSeriesByManagersInBrigade(scopeId, range, indicatorIds);
171
+
172
+            } else if ("MANAGER".equals(scopeType) && scopeId != null) {
173
+                // 单条主管 series
174
+                series = buildRadarSeriesForSingleManager(scopeId, range, indicatorIds);
175
+
176
+            } else if ("USER".equals(scopeType) && scopeId != null) {
177
+                // 单条用户 series
178
+                series = buildRadarSeriesForSingleUser(scopeId, range, indicatorIds);
179
+
180
+            } else {
181
+                // STATION 或 role-based:各大队各一条 series
182
+                Long stationDeptId = getStationDeptId(scopeType, scopeId);
183
+                if (stationDeptId == null) {
184
+                    Map<String, Object> noShow = new HashMap<>();
185
+                    noShow.put("show", false);
186
+                    return HttpResult.success(noShow);
187
+                }
188
+                series = buildRadarSeriesByBrigadesInStation(stationDeptId, range, indicatorIds);
189
+            }
190
+
191
+            Map<String, Object> result = new LinkedHashMap<>();
192
+            result.put("show", true);
193
+            result.put("indicators", indicators);
194
+            result.put("series", series);
195
+            return HttpResult.success(result);
196
+
197
+        } catch (Exception e) {
198
+            log.error("获取问题分布对比失败(PC)", e);
199
+            return HttpResult.error("获取问题分布对比失败:" + e.getMessage());
200
+        }
201
+    }
202
+
203
+    // =====================================================================
204
+    // 私有工具方法
205
+    // =====================================================================
206
+
207
+    /**
208
+     * 根据 scopeType/scopeId 获取错误明细
209
+     */
210
+    private List<DailyTaskDetail> getErrorDetailsByScope(String scopeType, Long scopeId,
211
+                                                          LocalDate start, LocalDate end) {
212
+        if ("MANAGER".equals(scopeType) && scopeId != null) {
213
+            return getErrorDetailsByDepartmentId(scopeId, start, end);
214
+        } else if ("USER".equals(scopeType) && scopeId != null) {
215
+            return getErrorDetailsByUserId(scopeId, start, end);
216
+        } else if ("BRIGADE".equals(scopeType) && scopeId != null) {
217
+            return getErrorDetailsBySubDepts(scopeId, start, end);
218
+        } else {
219
+            // STATION 或 role-based
220
+            Long deptId = getStationDeptId(scopeType, scopeId);
221
+            if (deptId == null) {
222
+                // 非站长,按角色判断
223
+                RoleInfo role = getRoleInfo();
224
+                if (role.isBrigade) {
225
+                    return getErrorDetailsBySubDepts(role.userDeptId, start, end);
226
+                } else if (role.isKezhang) {
227
+                    return getErrorDetailsByDepartmentId(role.userDeptId, start, end);
228
+                } else {
229
+                    return getErrorDetailsByDeptId(role.userDeptId, start, end);
230
+                }
231
+            }
232
+            List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(deptId);
233
+            Set<Long> allDeptIds = new HashSet<>();
234
+            allDeptIds.add(deptId);
235
+            if (subDepts != null) subDepts.forEach(d -> allDeptIds.add(d.getDeptId()));
236
+            return getErrorDetailsByDeptIds(allDeptIds, start, end);
237
+        }
238
+    }
239
+
240
+    /**
241
+     * 获取站级 deptId:
242
+     * - scopeType=STATION → 取 scopeId(若有)或当前用户 deptId(需是站长角色)
243
+     * - scopeType=null → 若当前用户是站长则取其 deptId,否则返回 null
244
+     */
245
+    private Long getStationDeptId(String scopeType, Long scopeId) {
246
+        if ("STATION".equals(scopeType)) {
247
+            if (scopeId != null) return scopeId;
248
+            return SecurityUtils.getLoginUser().getDeptId();
249
+        }
250
+        RoleInfo role = getRoleInfo();
251
+        if (role.isStation) return role.userDeptId;
252
+        return null;
253
+    }
254
+
255
+    /**
256
+     * 雷达图:站下各大队各一条 series
257
+     */
258
+    private List<Map<String, Object>> buildRadarSeriesByBrigadesInStation(
259
+            Long stationDeptId, LocalDate[] range, List<Long> indicatorIds) {
260
+
261
+        List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(stationDeptId);
262
+        List<SysDept> brigadeDepts = subDepts == null ? Collections.emptyList() :
263
+                subDepts.stream()
264
+                        .filter(d -> stationDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
265
+                        .collect(Collectors.toList());
266
+
267
+        List<Map<String, Object>> series = new ArrayList<>();
268
+        for (SysDept brigade : brigadeDepts) {
269
+            List<DailyTaskDetail> details = getErrorDetailsBySubDepts(brigade.getDeptId(), range[0], range[1]);
270
+            series.add(buildRadarSeriesItem(brigade.getDeptName(), brigade.getDeptId(), details, indicatorIds));
271
+        }
272
+        return series;
273
+    }
274
+
275
+    /**
276
+     * 雷达图:大队下各主管各一条 series
277
+     */
278
+    private List<Map<String, Object>> buildRadarSeriesByManagersInBrigade(
279
+            Long brigadeId, LocalDate[] range, List<Long> indicatorIds) {
280
+
281
+        List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(brigadeId);
282
+        List<SysDept> managerDepts = subDepts == null ? Collections.emptyList() :
283
+                subDepts.stream()
284
+                        .filter(d -> brigadeId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
285
+                        .collect(Collectors.toList());
286
+
287
+        List<Map<String, Object>> series = new ArrayList<>();
288
+        for (SysDept manager : managerDepts) {
289
+            List<DailyTaskDetail> details = getErrorDetailsByDepartmentId(manager.getDeptId(), range[0], range[1]);
290
+            series.add(buildRadarSeriesItem(manager.getDeptName(), manager.getDeptId(), details, indicatorIds));
291
+        }
292
+        return series;
293
+    }
294
+
295
+    /**
296
+     * 雷达图:单条主管 series
297
+     */
298
+    private List<Map<String, Object>> buildRadarSeriesForSingleManager(
299
+            Long managerId, LocalDate[] range, List<Long> indicatorIds) {
300
+
301
+        List<DailyTaskDetail> details = getErrorDetailsByDepartmentId(managerId, range[0], range[1]);
302
+        String name = getManagerName(managerId);
303
+        return Collections.singletonList(buildRadarSeriesItem(name, managerId, details, indicatorIds));
304
+    }
305
+
306
+    /**
307
+     * 雷达图:单条用户 series
308
+     */
309
+    private List<Map<String, Object>> buildRadarSeriesForSingleUser(
310
+            Long userId, LocalDate[] range, List<Long> indicatorIds) {
311
+
312
+        List<DailyTaskDetail> details = getErrorDetailsByUserId(userId, range[0], range[1]);
313
+        String name = getUserName(userId, details);
314
+        Map<String, Object> item = buildRadarSeriesItemWithUserId(name, userId, details, indicatorIds);
315
+        return Collections.singletonList(item);
316
+    }
317
+
318
+    /**
319
+     * 构建雷达图单条 series(deptId)
320
+     */
321
+    private Map<String, Object> buildRadarSeriesItem(String name, Long deptId,
322
+                                                      List<DailyTaskDetail> details,
323
+                                                      List<Long> indicatorIds) {
324
+        Map<String, BaseCheckCategory> qcIdToLevel1 = buildQcIdToLevel1Map(details);
325
+        Map<Long, Long> countMap = details.stream()
326
+                .filter(d -> qcIdToLevel1.get(d.getDtdModuleId()) != null)
327
+                .collect(Collectors.groupingBy(
328
+                        d -> qcIdToLevel1.get(d.getDtdModuleId()).getId(),
329
+                        Collectors.counting()));
330
+
331
+        List<Long> data = indicatorIds.stream()
332
+                .map(id -> countMap.getOrDefault(id, 0L))
333
+                .collect(Collectors.toList());
334
+
335
+        Map<String, Object> s = new LinkedHashMap<>();
336
+        s.put("name", name);
337
+        s.put("deptId", deptId);
338
+        s.put("data", data);
339
+        return s;
340
+    }
341
+
342
+    /**
343
+     * 构建雷达图单条 series(userId)
344
+     */
345
+    private Map<String, Object> buildRadarSeriesItemWithUserId(String name, Long userId,
346
+                                                                List<DailyTaskDetail> details,
347
+                                                                List<Long> indicatorIds) {
348
+        Map<String, BaseCheckCategory> qcIdToLevel1 = buildQcIdToLevel1Map(details);
349
+        Map<Long, Long> countMap = details.stream()
350
+                .filter(d -> qcIdToLevel1.get(d.getDtdModuleId()) != null)
351
+                .collect(Collectors.groupingBy(
352
+                        d -> qcIdToLevel1.get(d.getDtdModuleId()).getId(),
353
+                        Collectors.counting()));
354
+
355
+        List<Long> data = indicatorIds.stream()
356
+                .map(id -> countMap.getOrDefault(id, 0L))
357
+                .collect(Collectors.toList());
358
+
359
+        Map<String, Object> s = new LinkedHashMap<>();
360
+        s.put("name", name);
361
+        s.put("userId", userId);
362
+        s.put("data", data);
363
+        return s;
364
+    }
365
+
366
+    private RoleInfo getRoleInfo() {
367
+        LoginUser loginUser = SecurityUtils.getLoginUser();
368
+        List<String> roleKeys = loginUser.getUser().getRoles().stream()
369
+                .map(SysRole::getRoleKey).collect(Collectors.toList());
370
+        Long userDeptId = loginUser.getDeptId();
371
+        Long userId = loginUser.getUserId();
372
+
373
+        boolean isStation = userId == 1L
374
+                || roleKeys.contains(RoleTypeEnum.admin.getCode())
375
+                || roleKeys.contains(RoleTypeEnum.test.getCode())
376
+                || roleKeys.contains(RoleTypeEnum.zhijianke.getCode());
377
+        boolean isBrigade = !isStation && (roleKeys.contains(RoleTypeEnum.jingli.getCode())
378
+                || roleKeys.contains(RoleTypeEnum.xingzheng.getCode()));
379
+        boolean isKezhang = !isStation && !isBrigade && roleKeys.contains(RoleTypeEnum.kezhang.getCode());
380
+
381
+        RoleInfo info = new RoleInfo();
382
+        info.isStation = isStation;
383
+        info.isBrigade = isBrigade;
384
+        info.isKezhang = isKezhang;
385
+        info.userDeptId = userDeptId;
386
+        return info;
387
+    }
388
+
389
+    private static class RoleInfo {
390
+        boolean isStation;
391
+        boolean isBrigade;
392
+        boolean isKezhang;
393
+        Long userDeptId;
394
+    }
395
+
396
+    private List<DailyTaskDetail> getErrorDetailsByDeptIds(Set<Long> deptIds, LocalDate start, LocalDate end) {
397
+        if (deptIds == null || deptIds.isEmpty()) return Collections.emptyList();
398
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
399
+        taskWrapper.in(DailyTask::getDtDeptId, deptIds);
400
+        taskWrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
401
+        taskWrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
402
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
403
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
404
+        return getErrorDetailsFromTasks(tasks);
405
+    }
406
+
407
+    private List<DailyTaskDetail> getErrorDetailsByDepartmentId(Long departmentId, LocalDate start, LocalDate end) {
408
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
409
+        taskWrapper.eq(DailyTask::getDtDepartmentId, departmentId);
410
+        taskWrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
411
+        taskWrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
412
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
413
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
414
+        return getErrorDetailsFromTasks(tasks);
415
+    }
416
+
417
+    private List<DailyTaskDetail> getErrorDetailsByDeptId(Long deptId, LocalDate start, LocalDate end) {
418
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
419
+        taskWrapper.eq(DailyTask::getDtDeptId, deptId);
420
+        taskWrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
421
+        taskWrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
422
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
423
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
424
+        return getErrorDetailsFromTasks(tasks);
425
+    }
426
+
427
+    private List<DailyTaskDetail> getErrorDetailsByUserId(Long userId, LocalDate start, LocalDate end) {
428
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
429
+        taskWrapper.eq(DailyTask::getDtUserId, userId);
430
+        taskWrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
431
+        taskWrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
432
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
433
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
434
+        return getErrorDetailsFromTasks(tasks);
435
+    }
436
+
437
+    /**
438
+     * 查询上级部门(大队)下所有直接子部门(主管)的错误明细
439
+     */
440
+    private List<DailyTaskDetail> getErrorDetailsBySubDepts(Long parentDeptId, LocalDate start, LocalDate end) {
441
+        List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(parentDeptId);
442
+        if (subDepts == null || subDepts.isEmpty()) return Collections.emptyList();
443
+        Set<Long> managerIds = subDepts.stream()
444
+                .filter(d -> parentDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
445
+                .map(SysDept::getDeptId)
446
+                .collect(Collectors.toSet());
447
+        if (managerIds.isEmpty()) return Collections.emptyList();
448
+        LambdaQueryWrapper<DailyTask> taskWrapper = new LambdaQueryWrapper<>();
449
+        taskWrapper.in(DailyTask::getDtDepartmentId, managerIds);
450
+        taskWrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
451
+        taskWrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
452
+        taskWrapper.eq(DailyTask::getDelStatus, 0);
453
+        List<DailyTask> tasks = dailyTaskMapper.selectList(taskWrapper);
454
+        return getErrorDetailsFromTasks(tasks);
455
+    }
456
+
457
+    private List<DailyTaskDetail> getErrorDetailsFromTasks(List<DailyTask> tasks) {
458
+        if (tasks == null || tasks.isEmpty()) return Collections.emptyList();
459
+        Set<String> taskIds = tasks.stream().map(DailyTask::getDtId).collect(Collectors.toSet());
460
+        LambdaQueryWrapper<DailyTaskDetail> detailWrapper = new LambdaQueryWrapper<>();
461
+        detailWrapper.in(DailyTaskDetail::getDtdTaskId, taskIds);
462
+        detailWrapper.eq(DailyTaskDetail::getDtdIsCorrect, false);
463
+        detailWrapper.eq(DailyTaskDetail::getDelStatus, 0);
464
+        return dailyTaskDetailMapper.selectList(detailWrapper);
465
+    }
466
+
467
+    private Map<String, BaseCheckCategory> buildQcIdToLevel1Map(List<DailyTaskDetail> details) {
468
+        Map<String, BaseCheckCategory> result = new HashMap<>();
469
+        Map<String, BaseCheckCategory> qcNameToLevel1Cache = new HashMap<>();
470
+
471
+        for (DailyTaskDetail detail : details) {
472
+            String qcId = detail.getDtdModuleId();
473
+            if (qcId == null || qcId.trim().isEmpty() || result.containsKey(qcId)) continue;
474
+
475
+            LambdaQueryWrapper<QuesCat> catWrapper = new LambdaQueryWrapper<>();
476
+            catWrapper.eq(QuesCat::getQcId, qcId);
477
+            catWrapper.eq(QuesCat::getDelStatus, 0);
478
+            QuesCat quesCat = quesCatService.getOne(catWrapper);
479
+
480
+            if (quesCat == null) {
481
+                result.put(qcId, null);
482
+                continue;
483
+            }
484
+
485
+            String qcName = quesCat.getQcName();
486
+            if (qcNameToLevel1Cache.containsKey(qcName)) {
487
+                result.put(qcId, qcNameToLevel1Cache.get(qcName));
488
+            } else {
489
+                BaseCheckCategory level1 = baseCheckCategoryService.selectLevel1ByLevel2Name(qcName);
490
+                qcNameToLevel1Cache.put(qcName, level1);
491
+                result.put(qcId, level1);
492
+            }
493
+        }
494
+        return result;
495
+    }
496
+
497
+    private LocalDate[] resolveDateRange(String dateRangeQueryType, Integer year, Integer quarter, Integer month) {
498
+        LocalDate today = LocalDate.now();
499
+        int y = (year != null) ? year : today.getYear();
500
+        String type = (dateRangeQueryType != null) ? dateRangeQueryType : "MONTH";
501
+        LocalDate start, end;
502
+        switch (type) {
503
+            case "YEAR":
504
+                start = LocalDate.of(y, 1, 1);
505
+                end = LocalDate.of(y, 12, 31);
506
+                break;
507
+            case "QUARTER":
508
+                int q = (quarter != null) ? quarter : 1;
509
+                start = LocalDate.of(y, (q - 1) * 3 + 1, 1);
510
+                end = start.plusMonths(3).minusDays(1);
511
+                break;
512
+            case "MONTH":
513
+            default:
514
+                int m = (month != null) ? month : today.getMonthValue();
515
+                start = LocalDate.of(y, m, 1);
516
+                end = start.withDayOfMonth(start.lengthOfMonth());
517
+                break;
518
+        }
519
+        return new LocalDate[]{start, end};
520
+    }
521
+
522
+    private String getManagerName(Long managerId) {
523
+        try {
524
+            SysDept dept = sysDeptMapper.selectDeptById(managerId);
525
+            if (dept != null) return dept.getDeptName();
526
+        } catch (Exception ignored) {
527
+        }
528
+        return "主管" + managerId;
529
+    }
530
+
531
+    private String getUserName(Long userId, List<DailyTaskDetail> details) {
532
+        if (details.isEmpty()) return "用户" + userId;
533
+        Set<String> taskIds = details.stream().map(DailyTaskDetail::getDtdTaskId).collect(Collectors.toSet());
534
+        LambdaQueryWrapper<DailyTask> w = new LambdaQueryWrapper<>();
535
+        w.in(DailyTask::getDtId, taskIds);
536
+        w.eq(DailyTask::getDelStatus, 0);
537
+        w.last("LIMIT 1");
538
+        DailyTask task = dailyTaskMapper.selectOne(w);
539
+        if (task != null && task.getDtUserName() != null) return task.getDtUserName();
540
+        return "用户" + userId;
541
+    }
542
+}