Bläddra i källkod

抽问抽答完成趋势改为多期聚合对比(适配美兰4级架构)

YEAR返回[去年,今年],QUARTER返回[去年同季度,上季度,本季度],
MONTH返回[去年同月,上月,本月],每期返回聚合完成数,
携带同比(yoyRate)和环比(chainRatio)变化率。
新增scopeType/scopeId参数:STATION按大队分组+全站汇总,
BRIGADE按主管分组,MANAGER/USER单条series,默认按登录角色判断。
simonlll 3 veckor sedan
förälder
incheckning
1b1360a754

+ 289 - 140
airport-admin/src/main/java/com/sundot/airport/web/controller/exam/DailyExamController.java

@@ -5,8 +5,6 @@ import com.sundot.airport.common.core.domain.entity.SysDept;
5 5
 import com.sundot.airport.common.core.domain.entity.SysRole;
6 6
 import com.sundot.airport.common.core.domain.model.LoginUser;
7 7
 import com.sundot.airport.common.enums.RoleTypeEnum;
8
-import com.sundot.airport.common.core.domain.SysAnalysisReportParamDto;
9
-import com.sundot.airport.common.utils.DateRangeQueryUtils;
10 8
 import com.sundot.airport.common.utils.SecurityUtils;
11 9
 import com.sundot.airport.exam.adapter.DailyTaskToExamAdapter;
12 10
 import com.sundot.airport.exam.common.ClientTypeConstant;
@@ -34,10 +32,11 @@ import org.springframework.validation.annotation.Validated;
34 32
 import org.springframework.web.bind.annotation.*;
35 33
 
36 34
 import javax.annotation.Resource;
35
+import java.math.BigDecimal;
36
+import java.math.RoundingMode;
37 37
 import java.sql.Date;
38 38
 import java.text.SimpleDateFormat;
39 39
 import java.time.LocalDate;
40
-import java.time.format.DateTimeFormatter;
41 40
 import java.util.*;
42 41
 import java.util.stream.Collectors;
43 42
 
@@ -292,16 +291,27 @@ public class DailyExamController {
292 291
 
293 292
     /**
294 293
      * 抽问抽答完成趋势
295
-     * 美兰4级架构:STATION > 大队(BRIGADE) > 主管(MANAGER) > 班组(TEAMS)
296
-     * 站长/管理员/质检科:各大队完成趋势折线
297
-     * 大队长(经理/行政):本大队各主管完成趋势折线
298
-     * 主管:本主管室完成趋势(单条折线)
299
-     * 班组长/安检员:show=false
294
+     * 美兰4级架构:STATION > 大队(BRIGADE) > 主管(MANAGER) > 班组
295
+     * 始终返回多期对比数据:
296
+     *   YEAR:[去年, 今年] 含同比
297
+     *   QUARTER:[去年同季度, 上季度, 本季度] 含同比+环比
298
+     *   MONTH:[去年同月, 上月, 本月] 含同比+环比
300 299
      *
301
-     * @param param dateRangeQueryType=YEAR|QUARTER|MONTH,year,quarter,month
300
+     * @param dateRangeQueryType 时间类型:YEAR/QUARTER/MONTH,默认 MONTH
301
+     * @param year               年份,默认当前年
302
+     * @param quarter            季度 1-4,dateRangeQueryType=QUARTER 时有效
303
+     * @param month              月份 1-12,dateRangeQueryType=MONTH 时有效
304
+     * @param scopeType          统计范围:STATION/BRIGADE/MANAGER/USER,不传则按登录角色自动判断
305
+     * @param scopeId            统计范围ID(deptId 或 userId)
302 306
      */
303 307
     @GetMapping("/completion-trend")
304
-    public HttpResult<Map<String, Object>> getCompletionTrend(SysAnalysisReportParamDto param) {
308
+    public HttpResult<Map<String, Object>> getCompletionTrend(
309
+            @RequestParam(required = false, defaultValue = "MONTH") String dateRangeQueryType,
310
+            @RequestParam(required = false) Integer year,
311
+            @RequestParam(required = false) Integer quarter,
312
+            @RequestParam(required = false) Integer month,
313
+            @RequestParam(required = false) String scopeType,
314
+            @RequestParam(required = false) Long scopeId) {
305 315
 
306 316
         try {
307 317
             LoginUser loginUser = SecurityUtils.getLoginUser();
@@ -316,100 +326,29 @@ public class DailyExamController {
316 326
                     || roleKeys.contains(RoleTypeEnum.zhijianke.getCode());
317 327
             boolean isBrigade = !isStation && (roleKeys.contains(RoleTypeEnum.jingli.getCode())
318 328
                     || roleKeys.contains(RoleTypeEnum.xingzheng.getCode()));
319
-            boolean isKezhang = !isStation && !isBrigade && roleKeys.contains(RoleTypeEnum.kezhang.getCode());
329
+            boolean isManager = !isStation && !isBrigade && roleKeys.contains(RoleTypeEnum.kezhang.getCode());
320 330
 
321
-            // 班组长及以下不显示此tab
322
-            if (!isStation && !isBrigade && !isKezhang) {
331
+            // 班组长及以下不显示(未传 scopeType 且无管理权限)
332
+            if (scopeType == null && !isStation && !isBrigade && !isManager) {
323 333
                 Map<String, Object> noShow = new HashMap<>();
324 334
                 noShow.put("show", false);
325 335
                 return HttpResult.success(noShow);
326 336
             }
327 337
 
328
-            LocalDate[] range = resolveDateRange(param);
329
-            LocalDate start = range[0];
330
-            LocalDate end = range[1];
331
-            boolean byMonth = "YEAR".equals(param != null ? param.getDateRangeQueryType() : null);
332
-            List<String> xAxis = buildXAxis(start, end, byMonth);
333
-            List<Map<String, Object>> series = new ArrayList<>();
334
-
335
-            if (isStation) {
336
-                // 站长:查该站所有子部门,按大队分组
337
-                List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(userDeptId);
338
-                if (subDepts == null) subDepts = Collections.emptyList();
339
-
340
-                // 大队(站的直接下级)
341
-                List<SysDept> brigadeDepts = subDepts.stream()
342
-                        .filter(d -> userDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
343
-                        .collect(Collectors.toList());
344
-
345
-                // 构建 主管deptId -> 所属大队deptId 的映射
346
-                Map<Long, Long> managerToBrigadeId = new HashMap<>();
347
-                for (SysDept d : subDepts) {
348
-                    if ("0".equals(d.getDelFlag()) && d.getParentId() != null && !userDeptId.equals(d.getParentId())) {
349
-                        // 非直接下级,其parentId为某个大队,记录映射(主管级别)
350
-                        managerToBrigadeId.put(d.getDeptId(), d.getParentId());
351
-                    }
352
-                }
353
-
354
-                // 查全站所有任务
355
-                Set<Long> allDeptIds = new HashSet<>();
356
-                allDeptIds.add(userDeptId);
357
-                subDepts.forEach(d -> allDeptIds.add(d.getDeptId()));
358
-                List<DailyTask> allTasks = queryTasksByDeptIds(allDeptIds, start, end);
359
-
360
-                // 按大队分组生成折线
361
-                for (SysDept brigade : brigadeDepts) {
362
-                    Long brigadeId = brigade.getDeptId();
363
-                    List<DailyTask> brigadeTasks = allTasks.stream()
364
-                            .filter(t -> {
365
-                                Long managerId = t.getDtDepartmentId();
366
-                                if (managerId == null) return false;
367
-                                Long parentBrigadeId = managerToBrigadeId.get(managerId);
368
-                                return brigadeId.equals(parentBrigadeId);
369
-                            })
370
-                            .collect(Collectors.toList());
371
-                    Map<String, Object> item = new LinkedHashMap<>();
372
-                    item.put("name", brigade.getDeptName());
373
-                    item.put("deptId", brigadeId);
374
-                    item.put("data", buildSeriesData(xAxis, brigadeTasks, byMonth));
375
-                    series.add(item);
376
-                }
377
-
378
-            } else if (isBrigade) {
379
-                // 大队长:查本大队下各主管的任务
380
-                List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(userDeptId);
381
-                if (subDepts == null) subDepts = Collections.emptyList();
382
-
383
-                // 主管(大队的直接下级)
384
-                List<SysDept> managerDepts = subDepts.stream()
385
-                        .filter(d -> userDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
386
-                        .collect(Collectors.toList());
387
-
388
-                for (SysDept manager : managerDepts) {
389
-                    List<DailyTask> tasks = queryTasksByDepartmentId(manager.getDeptId(), start, end);
390
-                    Map<String, Object> item = new LinkedHashMap<>();
391
-                    item.put("name", manager.getDeptName());
392
-                    item.put("deptId", manager.getDeptId());
393
-                    item.put("data", buildSeriesData(xAxis, tasks, byMonth));
394
-                    series.add(item);
395
-                }
338
+            int curYear = (year != null) ? year : LocalDate.now().getYear();
339
+            int curQuarter = (quarter != null) ? quarter : ((LocalDate.now().getMonthValue() - 1) / 3 + 1);
340
+            int curMonth = (month != null) ? month : LocalDate.now().getMonthValue();
396 341
 
397
-            } else {
398
-                // 主管:本主管室所有任务,一条折线
399
-                List<DailyTask> tasks = queryTasksByDepartmentId(userDeptId, start, end);
400
-                String deptName = loginUser.getUser().getDept() != null
401
-                        ? loginUser.getUser().getDept().getDeptName() : "本主管";
402
-                Map<String, Object> item = new LinkedHashMap<>();
403
-                item.put("name", deptName);
404
-                item.put("deptId", userDeptId);
405
-                item.put("data", buildSeriesData(xAxis, tasks, byMonth));
406
-                series.add(item);
407
-            }
342
+            List<LocalDate[]> periods = buildPeriods(dateRangeQueryType, curYear, curQuarter, curMonth);
343
+            List<String> xAxis = buildXAxisLabels(dateRangeQueryType, curYear, curQuarter, curMonth);
408 344
 
409 345
             Map<String, Object> result = new LinkedHashMap<>();
410 346
             result.put("show", true);
411 347
             result.put("xAxis", xAxis);
412
-            result.put("series", series);
348
+            result.put("series", buildComparisonSeries(
349
+                    scopeType, scopeId, periods, dateRangeQueryType,
350
+                    isStation, isBrigade, isManager, userDeptId, loginUser));
351
+
413 352
             return HttpResult.success(result);
414 353
 
415 354
         } catch (Exception e) {
@@ -419,75 +358,273 @@ public class DailyExamController {
419 358
     }
420 359
 
421 360
     /**
422
-     * 根据 SysAnalysisReportParamDto 计算起止日期
423
-     * YEAR → 全年;QUARTER → 指定季度;MONTH → 指定月份;默认 → 当月
361
+     * 构建各期日期范围列表
362
+     * YEAR:[去年, 今年]
363
+     * QUARTER:[去年同季度, 上季度, 本季度]
364
+     * MONTH:[去年同月, 上月, 本月]
424 365
      */
425
-    private LocalDate[] resolveDateRange(SysAnalysisReportParamDto param) {
426
-        LocalDate today = LocalDate.now();
427
-        int year = (param != null && param.getYear() != null) ? param.getYear() : today.getYear();
428
-        String type = (param != null && param.getDateRangeQueryType() != null) ? param.getDateRangeQueryType() : "MONTH";
429
-        com.sundot.airport.common.core.domain.SysAnalysisReportDateRangeDto range;
430
-        switch (type) {
366
+    private List<LocalDate[]> buildPeriods(String type, int year, int quarter, int month) {
367
+        List<LocalDate[]> periods = new ArrayList<>();
368
+        switch (type == null ? "MONTH" : type) {
369
+            case "YEAR":
370
+                periods.add(new LocalDate[]{LocalDate.of(year - 1, 1, 1), LocalDate.of(year - 1, 12, 31)});
371
+                periods.add(new LocalDate[]{LocalDate.of(year, 1, 1), LocalDate.of(year, 12, 31)});
372
+                break;
373
+            case "QUARTER":
374
+                periods.add(resolveDateRange("QUARTER", year - 1, quarter, null));
375
+                int prevQ = quarter - 1;
376
+                int prevQYear = year;
377
+                if (prevQ < 1) { prevQ = 4; prevQYear = year - 1; }
378
+                periods.add(resolveDateRange("QUARTER", prevQYear, prevQ, null));
379
+                periods.add(resolveDateRange("QUARTER", year, quarter, null));
380
+                break;
381
+            case "MONTH":
382
+            default:
383
+                periods.add(resolveDateRange("MONTH", year - 1, null, month));
384
+                int prevM = month - 1;
385
+                int prevMYear = year;
386
+                if (prevM < 1) { prevM = 12; prevMYear = year - 1; }
387
+                periods.add(resolveDateRange("MONTH", prevMYear, null, prevM));
388
+                periods.add(resolveDateRange("MONTH", year, null, month));
389
+                break;
390
+        }
391
+        return periods;
392
+    }
393
+
394
+    /**
395
+     * 构建 X 轴中文标签列表
396
+     */
397
+    private List<String> buildXAxisLabels(String type, int year, int quarter, int month) {
398
+        List<String> labels = new ArrayList<>();
399
+        switch (type == null ? "MONTH" : type) {
431 400
             case "YEAR":
432
-                range = DateRangeQueryUtils.getYearRange(year, null);
401
+                labels.add((year - 1) + "年");
402
+                labels.add(year + "年");
433 403
                 break;
434 404
             case "QUARTER":
435
-                int quarter = (param.getQuarter() != null) ? param.getQuarter() : 1;
436
-                range = DateRangeQueryUtils.getQuarterRange(year, quarter, null);
405
+                labels.add((year - 1) + "年第" + quarter + "季度");
406
+                int prevQ = quarter - 1;
407
+                int prevQYear = year;
408
+                if (prevQ < 1) { prevQ = 4; prevQYear = year - 1; }
409
+                labels.add(prevQYear + "年第" + prevQ + "季度");
410
+                labels.add(year + "年第" + quarter + "季度");
437 411
                 break;
438
-            default: // MONTH
439
-                int month = (param != null && param.getMonth() != null) ? param.getMonth() : today.getMonthValue();
440
-                range = DateRangeQueryUtils.getMonthRange(year, month, null);
412
+            case "MONTH":
413
+            default:
414
+                labels.add((year - 1) + "年" + month + "月");
415
+                int prevM = month - 1;
416
+                int prevMYear = year;
417
+                if (prevM < 1) { prevM = 12; prevMYear = year - 1; }
418
+                labels.add(prevMYear + "年" + prevM + "月");
419
+                labels.add(year + "年" + month + "月");
441 420
                 break;
442 421
         }
443
-        return new LocalDate[]{
444
-                ((java.sql.Date) range.getStartDate()).toLocalDate(),
445
-                ((java.sql.Date) range.getEndDate()).toLocalDate()
446
-        };
422
+        return labels;
447 423
     }
448 424
 
449 425
     /**
450
-     * 生成X轴标签列表
451
-     * byMonth=true:每月一个标签(格式 yyyy-MM);false:每天一个标签(格式 yyyy-MM-dd)
426
+     * 构建多期对比 series 列表(适配美兰4级架构)
427
+     * STATION:各大队各一条 series + 全站汇总
428
+     * BRIGADE:该大队各主管各一条 series
429
+     * MANAGER/USER:单条 series
452 430
      */
453
-    private List<String> buildXAxis(LocalDate start, LocalDate end, boolean byMonth) {
454
-        List<String> xAxis = new ArrayList<>();
455
-        if (byMonth) {
456
-            LocalDate cursor = start.withDayOfMonth(1);
457
-            LocalDate endMonth = end.withDayOfMonth(1);
458
-            while (!cursor.isAfter(endMonth)) {
459
-                xAxis.add(cursor.format(DateTimeFormatter.ofPattern("yyyy-MM")));
460
-                cursor = cursor.plusMonths(1);
431
+    private List<Map<String, Object>> buildComparisonSeries(
432
+            String scopeType, Long scopeId, List<LocalDate[]> periods, String dateRangeQueryType,
433
+            boolean isStation, boolean isBrigade, boolean isManager, Long userDeptId, LoginUser loginUser) {
434
+
435
+        boolean isYearType = "YEAR".equals(dateRangeQueryType);
436
+
437
+        if ("MANAGER".equals(scopeType) && scopeId != null) {
438
+            String name = "主管" + scopeId;
439
+            List<Integer> data = new ArrayList<>();
440
+            for (LocalDate[] p : periods) {
441
+                List<DailyTask> tasks = queryTasksByDepartmentId(scopeId, p[0], p[1]);
442
+                if (name.startsWith("主管")) {
443
+                    name = tasks.stream().map(DailyTask::getDtDepartmentName)
444
+                            .filter(Objects::nonNull).findFirst().orElse(name);
445
+                }
446
+                data.add(countCompleted(tasks));
447
+            }
448
+            return Collections.singletonList(buildSeriesItem(name, scopeId, null, data, isYearType));
449
+
450
+        } else if ("USER".equals(scopeType) && scopeId != null) {
451
+            String name = "用户" + scopeId;
452
+            List<Integer> data = new ArrayList<>();
453
+            for (LocalDate[] p : periods) {
454
+                List<DailyTask> tasks = queryTasksByUserId(scopeId, p[0], p[1]);
455
+                if (name.startsWith("用户")) {
456
+                    name = tasks.stream().map(DailyTask::getDtUserName)
457
+                            .filter(Objects::nonNull).findFirst().orElse(name);
458
+                }
459
+                data.add(countCompleted(tasks));
461 460
             }
461
+            return Collections.singletonList(buildSeriesItem(name, null, scopeId, data, isYearType));
462
+
463
+        } else if ("BRIGADE".equals(scopeType) && scopeId != null) {
464
+            return buildBrigadeSeries(scopeId, periods, isYearType);
465
+
462 466
         } else {
463
-            LocalDate cursor = start;
464
-            while (!cursor.isAfter(end)) {
465
-                xAxis.add(cursor.toString());
466
-                cursor = cursor.plusDays(1);
467
+            Long stationDeptId = ("STATION".equals(scopeType) && scopeId != null) ? scopeId : userDeptId;
468
+            if (isStation || "STATION".equals(scopeType)) {
469
+                return buildStationSeries(stationDeptId, periods, isYearType);
470
+            } else if (isBrigade) {
471
+                return buildBrigadeSeries(userDeptId, periods, isYearType);
472
+            } else {
473
+                // isManager:单条 series
474
+                String deptName = loginUser.getUser().getDept() != null
475
+                        ? loginUser.getUser().getDept().getDeptName() : "本主管";
476
+                List<Integer> data = new ArrayList<>();
477
+                for (LocalDate[] p : periods) {
478
+                    data.add(countCompleted(queryTasksByDepartmentId(userDeptId, p[0], p[1])));
479
+                }
480
+                return Collections.singletonList(buildSeriesItem(deptName, userDeptId, null, data, isYearType));
467 481
             }
468 482
         }
469
-        return xAxis;
470 483
     }
471 484
 
472 485
     /**
473
-     * 构建某条折线的数据数组,与 xAxis 对齐
486
+     * 站长视角:按大队分组,各期聚合 + 全站汇总
474 487
      */
475
-    private List<Integer> buildSeriesData(List<String> xAxis, List<DailyTask> tasks, boolean byMonth) {
476
-        SimpleDateFormat sdf = new SimpleDateFormat(byMonth ? "yyyy-MM" : "yyyy-MM-dd");
477
-        Map<String, Long> countMap = tasks.stream()
478
-                .filter(t -> "COMPLETED".equals(t.getDtStatus()) && t.getDtBusinessDate() != null)
479
-                .collect(Collectors.groupingBy(
480
-                        t -> sdf.format(t.getDtBusinessDate()),
481
-                        Collectors.counting()));
482
-        List<Integer> data = new ArrayList<>();
483
-        for (String label : xAxis) {
484
-            data.add(countMap.getOrDefault(label, 0L).intValue());
488
+    private List<Map<String, Object>> buildStationSeries(Long stationDeptId, List<LocalDate[]> periods, boolean isYearType) {
489
+        List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(stationDeptId);
490
+        if (subDepts == null) subDepts = Collections.emptyList();
491
+
492
+        List<SysDept> brigadeDepts = subDepts.stream()
493
+                .filter(d -> stationDeptId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
494
+                .collect(Collectors.toList());
495
+
496
+        // 主管deptId -> 所属大队deptId
497
+        Map<Long, Long> managerToBrigadeId = new HashMap<>();
498
+        for (SysDept d : subDepts) {
499
+            if ("0".equals(d.getDelFlag()) && d.getParentId() != null && !stationDeptId.equals(d.getParentId())) {
500
+                managerToBrigadeId.put(d.getDeptId(), d.getParentId());
501
+            }
502
+        }
503
+
504
+        Set<Long> allDeptIds = new HashSet<>();
505
+        allDeptIds.add(stationDeptId);
506
+        subDepts.forEach(d -> allDeptIds.add(d.getDeptId()));
507
+
508
+        List<List<DailyTask>> periodTasks = new ArrayList<>();
509
+        for (LocalDate[] p : periods) {
510
+            periodTasks.add(queryTasksByDeptIds(allDeptIds, p[0], p[1]));
485 511
         }
486
-        return data;
512
+
513
+        List<Map<String, Object>> series = new ArrayList<>();
514
+        List<Integer> totalData = new ArrayList<>();
515
+        for (int i = 0; i < periods.size(); i++) totalData.add(0);
516
+
517
+        for (SysDept brigade : brigadeDepts) {
518
+            Long brigadeId = brigade.getDeptId();
519
+            List<Integer> data = new ArrayList<>();
520
+            for (int i = 0; i < periodTasks.size(); i++) {
521
+                int count = countCompleted(periodTasks.get(i).stream()
522
+                        .filter(t -> {
523
+                            Long managerId = t.getDtDepartmentId();
524
+                            if (managerId == null) return false;
525
+                            return brigadeId.equals(managerToBrigadeId.get(managerId));
526
+                        })
527
+                        .collect(Collectors.toList()));
528
+                data.add(count);
529
+                totalData.set(i, totalData.get(i) + count);
530
+            }
531
+            series.add(buildSeriesItem(brigade.getDeptName(), brigadeId, null, data, isYearType));
532
+        }
533
+        series.add(buildSeriesItem("全站", null, null, totalData, isYearType));
534
+        return series;
487 535
     }
488 536
 
489 537
     /**
490
-     * 按部门ID集合查询任务(用于站长视角:通过dtDeptId匹配所有子部门)
538
+     * 大队视角:按主管分组,各期聚合
539
+     */
540
+    private List<Map<String, Object>> buildBrigadeSeries(Long brigadeId, List<LocalDate[]> periods, boolean isYearType) {
541
+        List<SysDept> subDepts = sysDeptMapper.selectChildrenDeptById(brigadeId);
542
+        if (subDepts == null) subDepts = Collections.emptyList();
543
+
544
+        List<SysDept> managerDepts = subDepts.stream()
545
+                .filter(d -> brigadeId.equals(d.getParentId()) && "0".equals(d.getDelFlag()))
546
+                .collect(Collectors.toList());
547
+
548
+        List<Map<String, Object>> series = new ArrayList<>();
549
+        for (SysDept manager : managerDepts) {
550
+            List<Integer> data = new ArrayList<>();
551
+            for (LocalDate[] p : periods) {
552
+                data.add(countCompleted(queryTasksByDepartmentId(manager.getDeptId(), p[0], p[1])));
553
+            }
554
+            series.add(buildSeriesItem(manager.getDeptName(), manager.getDeptId(), null, data, isYearType));
555
+        }
556
+        return series;
557
+    }
558
+
559
+    /**
560
+     * 构建单条 series map,含同比/环比
561
+     * data[0]=去年同期,data[1]=上期(仅QUARTER/MONTH),data[last]=本期
562
+     */
563
+    private Map<String, Object> buildSeriesItem(String name, Long deptId, Long userId,
564
+                                                 List<Integer> data, boolean isYearType) {
565
+        Map<String, Object> s = new LinkedHashMap<>();
566
+        s.put("name", name);
567
+        if (deptId != null) s.put("deptId", deptId);
568
+        if (userId != null) s.put("userId", userId);
569
+        s.put("data", data);
570
+        int current = data.get(data.size() - 1);
571
+        // 同比:本期 vs 去年同期(data[0])
572
+        s.put("yoyRate", calcRate(current, data.get(0)));
573
+        // 环比:本期 vs 上一期(data[last-1]),仅 QUARTER/MONTH 有效
574
+        if (!isYearType && data.size() >= 3) {
575
+            s.put("chainRatio", calcRate(current, data.get(data.size() - 2)));
576
+        } else {
577
+            s.put("chainRatio", null);
578
+        }
579
+        return s;
580
+    }
581
+
582
+    /**
583
+     * 计算变化率百分比,base=0 时返回 null
584
+     */
585
+    private BigDecimal calcRate(int current, int base) {
586
+        if (base == 0) return null;
587
+        return BigDecimal.valueOf(current - base)
588
+                .multiply(BigDecimal.valueOf(100))
589
+                .divide(BigDecimal.valueOf(base), 2, RoundingMode.HALF_UP);
590
+    }
591
+
592
+    /**
593
+     * 统计 COMPLETED 任务数
594
+     */
595
+    private int countCompleted(List<DailyTask> tasks) {
596
+        return (int) tasks.stream().filter(t -> "COMPLETED".equals(t.getDtStatus())).count();
597
+    }
598
+
599
+    /**
600
+     * 根据 dateRangeQueryType/year/quarter/month 计算起止日期
601
+     */
602
+    private LocalDate[] resolveDateRange(String type, Integer year, Integer quarter, Integer month) {
603
+        LocalDate today = LocalDate.now();
604
+        int y = (year != null) ? year : today.getYear();
605
+        LocalDate start, end;
606
+        switch (type == null ? "MONTH" : type) {
607
+            case "YEAR":
608
+                start = LocalDate.of(y, 1, 1);
609
+                end = LocalDate.of(y, 12, 31);
610
+                break;
611
+            case "QUARTER":
612
+                int q = (quarter != null) ? quarter : 1;
613
+                start = LocalDate.of(y, (q - 1) * 3 + 1, 1);
614
+                end = start.plusMonths(3).minusDays(1);
615
+                break;
616
+            case "MONTH":
617
+            default:
618
+                int m = (month != null) ? month : today.getMonthValue();
619
+                start = LocalDate.of(y, m, 1);
620
+                end = start.withDayOfMonth(start.lengthOfMonth());
621
+                break;
622
+        }
623
+        return new LocalDate[]{start, end};
624
+    }
625
+
626
+    /**
627
+     * 按部门ID集合查询任务
491 628
      */
492 629
     private List<DailyTask> queryTasksByDeptIds(Set<Long> deptIds, LocalDate start, LocalDate end) {
493 630
         LambdaQueryWrapper<DailyTask> wrapper = new LambdaQueryWrapper<>();
@@ -499,7 +636,7 @@ public class DailyExamController {
499 636
     }
500 637
 
501 638
     /**
502
-     * 按主管ID查询任务(通过dtDepartmentId匹配
639
+     * 按主管ID查询任务(dtDepartmentId)
503 640
      */
504 641
     private List<DailyTask> queryTasksByDepartmentId(Long departmentId, LocalDate start, LocalDate end) {
505 642
         LambdaQueryWrapper<DailyTask> wrapper = new LambdaQueryWrapper<>();
@@ -509,4 +646,16 @@ public class DailyExamController {
509 646
         wrapper.eq(DailyTask::getDelStatus, 0);
510 647
         return dailyTaskMapper.selectList(wrapper);
511 648
     }
649
+
650
+    /**
651
+     * 按用户ID查询任务(dtUserId)
652
+     */
653
+    private List<DailyTask> queryTasksByUserId(Long userId, LocalDate start, LocalDate end) {
654
+        LambdaQueryWrapper<DailyTask> wrapper = new LambdaQueryWrapper<>();
655
+        wrapper.eq(DailyTask::getDtUserId, userId);
656
+        wrapper.ge(DailyTask::getDtBusinessDate, Date.valueOf(start));
657
+        wrapper.le(DailyTask::getDtBusinessDate, Date.valueOf(end));
658
+        wrapper.eq(DailyTask::getDelStatus, 0);
659
+        return dailyTaskMapper.selectList(wrapper);
660
+    }
512 661
 }