Quellcode durchsuchen

牦牛资产档案

wwh vor 1 Monat
Ursprung
Commit
7c374d05aa

+ 24 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/domain/dto/HomeScreenAppointmentStatDto.java

@@ -5,12 +5,36 @@ package com.ruoyi.web.modules.screen.domain.dto;
5 5
  */
6 6
 public class HomeScreenAppointmentStatDto
7 7
 {
8
+    private Integer statYear;
9
+
10
+    private Integer statMonth;
11
+
8 12
     private Integer month;
9 13
 
10 14
     private Integer providerType;
11 15
 
12 16
     private Integer count;
13 17
 
18
+    public Integer getStatYear()
19
+    {
20
+        return statYear;
21
+    }
22
+
23
+    public void setStatYear(Integer statYear)
24
+    {
25
+        this.statYear = statYear;
26
+    }
27
+
28
+    public Integer getStatMonth()
29
+    {
30
+        return statMonth;
31
+    }
32
+
33
+    public void setStatMonth(Integer statMonth)
34
+    {
35
+        this.statMonth = statMonth;
36
+    }
37
+
14 38
     public Integer getMonth()
15 39
     {
16 40
         return month;

+ 24 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/domain/dto/HomeScreenTrainingMonthDto.java

@@ -5,12 +5,36 @@ package com.ruoyi.web.modules.screen.domain.dto;
5 5
  */
6 6
 public class HomeScreenTrainingMonthDto
7 7
 {
8
+    private Integer statYear;
9
+
10
+    private Integer statMonth;
11
+
8 12
     private Integer month;
9 13
 
10 14
     private Integer activityCount;
11 15
 
12 16
     private Integer enrolledCount;
13 17
 
18
+    public Integer getStatYear()
19
+    {
20
+        return statYear;
21
+    }
22
+
23
+    public void setStatYear(Integer statYear)
24
+    {
25
+        this.statYear = statYear;
26
+    }
27
+
28
+    public Integer getStatMonth()
29
+    {
30
+        return statMonth;
31
+    }
32
+
33
+    public void setStatMonth(Integer statMonth)
34
+    {
35
+        this.statMonth = statMonth;
36
+    }
37
+
14 38
     public Integer getMonth()
15 39
     {
16 40
         return month;

+ 14 - 1
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/domain/vo/HomeScreenAppointmentMonthVo.java

@@ -4,7 +4,10 @@ package com.ruoyi.web.modules.screen.domain.vo;
4 4
  */
5 5
 public class HomeScreenAppointmentMonthVo
6 6
 {
7
-    /** 月份 1~12 */
7
+    /** 所属年份(自然年) */
8
+    private int statYear;
9
+
10
+    /** 所属月份 1~12 */
8 11
     private int month;
9 12
     /** 兽医预约次数 */
10 13
     private int vetCount;
@@ -13,6 +16,16 @@ public class HomeScreenAppointmentMonthVo
13 16
     /** 机构预约次数 */
14 17
     private int orgCount;
15 18
 
19
+    public int getStatYear()
20
+    {
21
+        return statYear;
22
+    }
23
+
24
+    public void setStatYear(int statYear)
25
+    {
26
+        this.statYear = statYear;
27
+    }
28
+
16 29
     public int getMonth()
17 30
     {
18 31
         return month;

+ 14 - 1
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/domain/vo/HomeScreenTrainingMonthVo.java

@@ -4,13 +4,26 @@ package com.ruoyi.web.modules.screen.domain.vo;
4 4
  */
5 5
 public class HomeScreenTrainingMonthVo
6 6
 {
7
-    /** 月份 1~12 */
7
+    /** 所属年份(自然年) */
8
+    private int statYear;
9
+
10
+    /** 所属月份 1~12 */
8 11
     private int month;
9 12
     /** 培训活动场次 */
10 13
     private int activityCount;
11 14
     /** 报名人次 */
12 15
     private int enrolledCount;
13 16
 
17
+    public int getStatYear()
18
+    {
19
+        return statYear;
20
+    }
21
+
22
+    public void setStatYear(int statYear)
23
+    {
24
+        this.statYear = statYear;
25
+    }
26
+
14 27
     public int getMonth()
15 28
     {
16 29
         return month;

+ 12 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/mapper/HomeScreenStatsMapper.java

@@ -42,6 +42,10 @@ public interface HomeScreenStatsMapper
42 42
     /** 当年预约按月+provider_type 分组 */
43 43
     List<HomeScreenAppointmentStatDto> selectAppointmentMonthlyByYear(@Param("statYear") int statYear);
44 44
 
45
+    /** 自然年月区间内预约按月+provider_type 分组({@code startYm}/{@code endYm} 为 yyyyMM) */
46
+    List<HomeScreenAppointmentStatDto> selectAppointmentMonthlyByYearMonthRange(
47
+            @Param("startYm") int startYm, @Param("endYm") int endYm);
48
+
45 49
     /** 惠农补贴按类型汇总金额(元) */
46 50
     List<HomeScreenSubsidyTypeSumDto> sumSubsidyAmountByTypeForYear(
47 51
             @Param("yearStart") Date yearStart,
@@ -57,8 +61,16 @@ public interface HomeScreenStatsMapper
57 61
     /** 培训趋势:按培训举办时间所在月统计 */
58 62
     List<HomeScreenTrainingMonthDto> selectTrainingTrendByTrainingYear(@Param("statYear") int statYear);
59 63
 
64
+    /** 自然年月区间内培训按月统计({@code startYm}/{@code endYm} 为 yyyyMM) */
65
+    List<HomeScreenTrainingMonthDto> selectTrainingTrendByTrainingYearMonthRange(
66
+            @Param("startYm") int startYm, @Param("endYm") int endYm);
67
+
60 68
     int countPublishedTrainingsWithTrainingTimeInYear(@Param("statYear") int statYear);
61 69
 
70
+    /** 自然年月区间内已发布且 training_time 落在区间内的培训条数 */
71
+    int countPublishedTrainingsWithTrainingTimeInYearMonthRange(
72
+            @Param("startYm") int startYm, @Param("endYm") int endYm);
73
+
62 74
     List<Integer> selectDistinctOutboundYears();
63 75
 
64 76
     List<Integer> selectDistinctAppointmentYears();

+ 6 - 6
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/service/impl/HomeScreenServiceImpl.java

@@ -114,22 +114,22 @@ public class HomeScreenServiceImpl implements IHomeScreenService
114 114
                 breedingIndustryStatsMapper.countAvailableGrassland(),
115 115
                 breedingIndustryStatsMapper.countInUseGrassland(toDate(statDate)),
116 116
                 breedingIndustryStatsMapper.selectDegradationGroupCounts()));
117
-        // 预约(兽医/专家/机构)、惠农补贴、农技课堂、培训趋势
118 117
         vo.setAppointment(HomeScreenSupport.buildAppointment(
119 118
                 homeScreenStatsMapper.selectAppointmentAnnualByYear(year),
120
-                homeScreenStatsMapper.selectAppointmentMonthlyByYear(year),
121
-                statDate));
119
+                homeScreenStatsMapper.selectAppointmentMonthlyByYearMonthRange(startYm, endYm),
120
+                anchor));
122 121
         vo.setSubsidy(HomeScreenSupport.buildSubsidy(
123 122
                 homeScreenStatsMapper.sumSubsidyAmountByTypeForYear(yearStart, yearEnd),
124 123
                 homeScreenStatsMapper.countSubsidyGrantInYear(yearStart, yearEnd) > 0));
125 124
         int videoCount = homeScreenStatsMapper.countVideoCoursesPublishedInYear(yearStart, yearEnd);
126 125
         int trainingPublishCount = homeScreenStatsMapper.countTrainingsPublishedInYear(yearStart, yearEnd);
127 126
         vo.setAgriClassroom(HomeScreenSupport.buildAgriClassroom(videoCount, trainingPublishCount));
128
-        boolean hasTraining = homeScreenStatsMapper.countPublishedTrainingsWithTrainingTimeInYear(year) > 0;
127
+        boolean hasTraining = homeScreenStatsMapper.countPublishedTrainingsWithTrainingTimeInYearMonthRange(
128
+                startYm, endYm) > 0;
129 129
         vo.setTrainingTrend(HomeScreenSupport.buildTrainingTrend(
130
-                homeScreenStatsMapper.selectTrainingTrendByTrainingYear(year),
130
+                homeScreenStatsMapper.selectTrainingTrendByTrainingYearMonthRange(startYm, endYm),
131 131
                 hasTraining,
132
-                statDate));
132
+                anchor));
133 133
         vo.setAvailableYears(HomeScreenSupport.buildAvailableYears(
134 134
                 homeScreenStatsMapper.selectDistinctOutboundYears(),
135 135
                 homeScreenStatsMapper.selectDistinctAppointmentYears(),

+ 76 - 69
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/support/HomeScreenSupport.java

@@ -284,6 +284,75 @@ public final class HomeScreenSupport
284 284
         return list;
285 285
     }
286 286
 
287
+    /**
288
+     * 线下服务预约:最近 12 个月堆叠柱(按时间升序,含 {@code statYear}、{@code month})。
289
+     */
290
+    public static List<HomeScreenAppointmentMonthVo> buildRolling12MonthAppointmentStacked(
291
+            List<HomeScreenAppointmentStatDto> rows, YearMonth anchor)
292
+    {
293
+        Map<Integer, Map<Integer, Integer>> byYmAndType = new HashMap<>();
294
+        if (rows != null)
295
+        {
296
+            for (HomeScreenAppointmentStatDto row : rows)
297
+            {
298
+                if (row.getStatYear() != null && row.getStatMonth() != null && row.getProviderType() != null)
299
+                {
300
+                    int key = yearMonthKey(YearMonth.of(row.getStatYear(), row.getStatMonth()));
301
+                    byYmAndType.computeIfAbsent(key, k -> new HashMap<>())
302
+                            .put(row.getProviderType(), row.getCount() != null ? row.getCount() : 0);
303
+                }
304
+            }
305
+        }
306
+        List<HomeScreenAppointmentMonthVo> stacked = new ArrayList<>();
307
+        YearMonth start = anchor.minusMonths(11);
308
+        for (int slot = 0; slot < 12; slot++)
309
+        {
310
+            YearMonth ym = start.plusMonths(slot);
311
+            Map<Integer, Integer> types = byYmAndType.getOrDefault(yearMonthKey(ym), Collections.emptyMap());
312
+            HomeScreenAppointmentMonthVo point = new HomeScreenAppointmentMonthVo();
313
+            point.setStatYear(ym.getYear());
314
+            point.setMonth(ym.getMonthValue());
315
+            point.setVetCount(types.getOrDefault(PROVIDER_VET, 0));
316
+            point.setExpertCount(types.getOrDefault(PROVIDER_EXPERT, 0));
317
+            point.setOrgCount(types.getOrDefault(PROVIDER_ORG, 0));
318
+            stacked.add(point);
319
+        }
320
+        return stacked;
321
+    }
322
+
323
+    /**
324
+     * 实战培训报名:最近 12 个月活动数/报名人数(按时间升序,含 {@code statYear}、{@code month})。
325
+     */
326
+    public static List<HomeScreenTrainingMonthVo> buildRolling12MonthTraining(
327
+            List<HomeScreenTrainingMonthDto> rows, YearMonth anchor)
328
+    {
329
+        Map<Integer, HomeScreenTrainingMonthDto> byYm = new HashMap<>();
330
+        if (rows != null)
331
+        {
332
+            for (HomeScreenTrainingMonthDto row : rows)
333
+            {
334
+                if (row.getStatYear() != null && row.getStatMonth() != null)
335
+                {
336
+                    byYm.put(yearMonthKey(YearMonth.of(row.getStatYear(), row.getStatMonth())), row);
337
+                }
338
+            }
339
+        }
340
+        List<HomeScreenTrainingMonthVo> monthly = new ArrayList<>();
341
+        YearMonth start = anchor.minusMonths(11);
342
+        for (int slot = 0; slot < 12; slot++)
343
+        {
344
+            YearMonth ym = start.plusMonths(slot);
345
+            HomeScreenTrainingMonthDto src = byYm.get(yearMonthKey(ym));
346
+            HomeScreenTrainingMonthVo point = new HomeScreenTrainingMonthVo();
347
+            point.setStatYear(ym.getYear());
348
+            point.setMonth(ym.getMonthValue());
349
+            point.setActivityCount(src != null && src.getActivityCount() != null ? src.getActivityCount() : 0);
350
+            point.setEnrolledCount(src != null && src.getEnrolledCount() != null ? src.getEnrolledCount() : 0);
351
+            monthly.add(point);
352
+        }
353
+        return monthly;
354
+    }
355
+
287 356
     /**
288 357
      * 公母牛月度存栏趋势(存栏填报表 {@code bull_total}/{@code cow_total} 全县按月合计);当前年未来月份填 0。
289 358
      */
@@ -457,8 +526,8 @@ public final class HomeScreenSupport
457 526
 
458 527
     public static HomeScreenAppointmentVo buildAppointment(
459 528
             List<HomeScreenAppointmentStatDto> annualRows,
460
-            List<HomeScreenAppointmentStatDto> monthlyRows,
461
-            LocalDate statDate)
529
+            List<HomeScreenAppointmentStatDto> rollingMonthlyRows,
530
+            YearMonth anchor)
462 531
     {
463 532
         int vet = 0;
464 533
         int expert = 0;
@@ -485,45 +554,11 @@ public final class HomeScreenSupport
485 554
                 }
486 555
             }
487 556
         }
488
-        Map<String, Integer> monthlyMap = new HashMap<>();
489
-        if (monthlyRows != null)
490
-        {
491
-            for (HomeScreenAppointmentStatDto row : monthlyRows)
492
-            {
493
-                if (row.getMonth() == null || row.getProviderType() == null || row.getCount() == null)
494
-                {
495
-                    continue;
496
-                }
497
-                monthlyMap.put(row.getMonth() + ":" + row.getProviderType(), row.getCount());
498
-            }
499
-        }
500
-        int futureAfter = statDate.getMonthValue();
501
-        boolean currentYear = statDate.getYear()
502
-                == LocalDate.now(ZoneId.of(BreedingIndustryRules.ZONE_SHANGHAI)).getYear();
503
-        List<HomeScreenAppointmentMonthVo> stacked = new ArrayList<>();
504
-        for (int month = 1; month <= 12; month++)
505
-        {
506
-            HomeScreenAppointmentMonthVo point = new HomeScreenAppointmentMonthVo();
507
-            point.setMonth(month);
508
-            if (currentYear && month > futureAfter)
509
-            {
510
-                point.setVetCount(0);
511
-                point.setExpertCount(0);
512
-                point.setOrgCount(0);
513
-            }
514
-            else
515
-            {
516
-                point.setVetCount(monthlyMap.getOrDefault(month + ":" + PROVIDER_VET, 0));
517
-                point.setExpertCount(monthlyMap.getOrDefault(month + ":" + PROVIDER_EXPERT, 0));
518
-                point.setOrgCount(monthlyMap.getOrDefault(month + ":" + PROVIDER_ORG, 0));
519
-            }
520
-            stacked.add(point);
521
-        }
522 557
         HomeScreenAppointmentVo vo = new HomeScreenAppointmentVo();
523 558
         vo.setAnnualVetCount(vet);
524 559
         vo.setAnnualExpertCount(expert);
525 560
         vo.setAnnualOrgCount(org);
526
-        vo.setMonthlyStacked(stacked);
561
+        vo.setMonthlyStacked(buildRolling12MonthAppointmentStacked(rollingMonthlyRows, anchor));
527 562
         return vo;
528 563
     }
529 564
 
@@ -571,44 +606,16 @@ public final class HomeScreenSupport
571 606
     }
572 607
 
573 608
     public static HomeScreenTrainingTrendVo buildTrainingTrend(
574
-            List<HomeScreenTrainingMonthDto> rows, boolean hasTrainingData, LocalDate statDate)
609
+            List<HomeScreenTrainingMonthDto> rollingRows, boolean hasTrainingData, YearMonth anchor)
575 610
     {
576
-        Map<Integer, HomeScreenTrainingMonthDto> byMonth = new HashMap<>();
577
-        if (rows != null)
611
+        List<HomeScreenTrainingMonthVo> monthly = buildRolling12MonthTraining(rollingRows, anchor);
612
+        if (!hasTrainingData)
578 613
         {
579
-            for (HomeScreenTrainingMonthDto row : rows)
580
-            {
581
-                if (row.getMonth() != null)
582
-                {
583
-                    byMonth.put(row.getMonth(), row);
584
-                }
585
-            }
586
-        }
587
-        int futureAfter = statDate.getMonthValue();
588
-        boolean currentYear = statDate.getYear()
589
-                == LocalDate.now(ZoneId.of(BreedingIndustryRules.ZONE_SHANGHAI)).getYear();
590
-        List<HomeScreenTrainingMonthVo> monthly = new ArrayList<>();
591
-        for (int month = 1; month <= 12; month++)
592
-        {
593
-            HomeScreenTrainingMonthVo point = new HomeScreenTrainingMonthVo();
594
-            point.setMonth(month);
595
-            if (!hasTrainingData)
614
+            for (HomeScreenTrainingMonthVo point : monthly)
596 615
             {
597 616
                 point.setActivityCount(0);
598 617
                 point.setEnrolledCount(0);
599 618
             }
600
-            else if (currentYear && month > futureAfter)
601
-            {
602
-                point.setActivityCount(0);
603
-                point.setEnrolledCount(0);
604
-            }
605
-            else
606
-            {
607
-                HomeScreenTrainingMonthDto src = byMonth.get(month);
608
-                point.setActivityCount(src != null && src.getActivityCount() != null ? src.getActivityCount() : 0);
609
-                point.setEnrolledCount(src != null && src.getEnrolledCount() != null ? src.getEnrolledCount() : 0);
610
-            }
611
-            monthly.add(point);
612 619
         }
613 620
         HomeScreenTrainingTrendVo vo = new HomeScreenTrainingTrendVo();
614 621
         vo.setHasTrainingData(hasTrainingData);

+ 33 - 0
baqing-admin/src/main/resources/mapper/screen/HomeScreenStatsMapper.xml

@@ -54,6 +54,19 @@
54 54
         group by month(appoint_date), provider_type
55 55
     </select>
56 56
 
57
+    <select id="selectAppointmentMonthlyByYearMonthRange"
58
+            resultType="com.ruoyi.web.modules.screen.domain.dto.HomeScreenAppointmentStatDto">
59
+        select year(appoint_date) as statYear,
60
+               month(appoint_date) as statMonth,
61
+               provider_type as providerType,
62
+               count(*) as count
63
+        from biz_service_appointment
64
+        where year(appoint_date) * 100 + month(appoint_date) &gt;= #{startYm}
65
+          and year(appoint_date) * 100 + month(appoint_date) &lt;= #{endYm}
66
+          and status not in (2, 3)
67
+        group by year(appoint_date), month(appoint_date), provider_type
68
+    </select>
69
+
57 70
     <select id="sumSubsidyAmountByTypeForYear"
58 71
             resultType="com.ruoyi.web.modules.screen.domain.dto.HomeScreenSubsidyTypeSumDto">
59 72
         select subsidy_type as subsidyType, coalesce(sum(subsidy_amount), 0) as amountSum
@@ -94,6 +107,26 @@
94 107
         group by month(training_time)
95 108
     </select>
96 109
 
110
+    <select id="selectTrainingTrendByTrainingYearMonthRange"
111
+            resultType="com.ruoyi.web.modules.screen.domain.dto.HomeScreenTrainingMonthDto">
112
+        select year(training_time) as statYear,
113
+               month(training_time) as statMonth,
114
+               count(*) as activityCount,
115
+               coalesce(sum(actual_enrolled_count), 0) as enrolledCount
116
+        from biz_practical_training
117
+        where del_flag = '0' and publish_status = 1
118
+          and year(training_time) * 100 + month(training_time) &gt;= #{startYm}
119
+          and year(training_time) * 100 + month(training_time) &lt;= #{endYm}
120
+        group by year(training_time), month(training_time)
121
+    </select>
122
+
123
+    <select id="countPublishedTrainingsWithTrainingTimeInYearMonthRange" resultType="int">
124
+        select count(*) from biz_practical_training
125
+        where del_flag = '0' and publish_status = 1
126
+          and year(training_time) * 100 + month(training_time) &gt;= #{startYm}
127
+          and year(training_time) * 100 + month(training_time) &lt;= #{endYm}
128
+    </select>
129
+
97 130
     <select id="countPublishedTrainingsWithTrainingTimeInYear" resultType="int">
98 131
         select count(*) from biz_practical_training
99 132
         where del_flag = '0' and publish_status = 1

+ 5 - 4
baqing-admin/src/test/java/com/ruoyi/web/modules/screen/service/impl/HomeScreenServiceImplTest.java

@@ -197,16 +197,17 @@ class HomeScreenServiceImplTest
197 197
         when(homeScreenStatsMapper.countTradeMarketForScreen()).thenReturn(0);
198 198
         when(homeScreenStatsMapper.selectAppointmentAnnualByYear(any(Integer.class)))
199 199
                 .thenReturn(Collections.emptyList());
200
-        when(homeScreenStatsMapper.selectAppointmentMonthlyByYear(any(Integer.class)))
200
+        when(homeScreenStatsMapper.selectAppointmentMonthlyByYearMonthRange(any(Integer.class), any(Integer.class)))
201 201
                 .thenReturn(Collections.emptyList());
202 202
         when(homeScreenStatsMapper.sumSubsidyAmountByTypeForYear(any(), any()))
203 203
                 .thenReturn(Collections.emptyList());
204 204
         when(homeScreenStatsMapper.countSubsidyGrantInYear(any(), any())).thenReturn(0);
205 205
         when(homeScreenStatsMapper.countVideoCoursesPublishedInYear(any(), any())).thenReturn(0);
206 206
         when(homeScreenStatsMapper.countTrainingsPublishedInYear(any(), any())).thenReturn(0);
207
-        when(homeScreenStatsMapper.countPublishedTrainingsWithTrainingTimeInYear(any(Integer.class))).thenReturn(0);
208
-        when(homeScreenStatsMapper.selectTrainingTrendByTrainingYear(any(Integer.class)))
209
-                .thenReturn(Collections.emptyList());
207
+        when(homeScreenStatsMapper.countPublishedTrainingsWithTrainingTimeInYearMonthRange(
208
+                any(Integer.class), any(Integer.class))).thenReturn(0);
209
+        when(homeScreenStatsMapper.selectTrainingTrendByTrainingYearMonthRange(
210
+                any(Integer.class), any(Integer.class))).thenReturn(Collections.emptyList());
210 211
         when(homeScreenStatsMapper.selectDistinctOutboundYears()).thenReturn(Collections.emptyList());
211 212
         when(homeScreenStatsMapper.selectDistinctAppointmentYears()).thenReturn(Collections.emptyList());
212 213
         when(homeScreenStatsMapper.selectDistinctSubsidyYears()).thenReturn(Collections.emptyList());

+ 41 - 2
baqing-admin/src/test/java/com/ruoyi/web/modules/screen/support/HomeScreenSupportTest.java

@@ -15,7 +15,9 @@ import org.junit.jupiter.api.Test;
15 15
 import com.ruoyi.web.modules.industryservice.domain.dto.BreedingIndustryYakSnapshot;
16 16
 import com.ruoyi.web.modules.industryservice.support.BreedingIndustryRules;
17 17
 import com.ruoyi.web.modules.screen.domain.dto.HomeScreenAppointmentStatDto;
18
+import com.ruoyi.web.modules.screen.domain.dto.HomeScreenAppointmentStatDto;
18 19
 import com.ruoyi.web.modules.screen.domain.dto.HomeScreenMonthCountDto;
20
+import com.ruoyi.web.modules.screen.domain.dto.HomeScreenTrainingMonthDto;
19 21
 import com.ruoyi.web.modules.screen.domain.vo.HomeScreenAgriClassroomVo;
20 22
 import com.ruoyi.web.modules.screen.domain.vo.HomeScreenAppointmentVo;
21 23
 import com.ruoyi.web.modules.screen.domain.vo.HomeScreenAgeBandVo;
@@ -141,12 +143,30 @@ class HomeScreenSupportTest
141 143
         HomeScreenAppointmentStatDto vet = appt(null, 1, 2);
142 144
         HomeScreenAppointmentStatDto expert = appt(null, 3, 1);
143 145
         HomeScreenAppointmentVo vo = HomeScreenSupport.buildAppointment(
144
-                Arrays.asList(vet, expert), Collections.emptyList(), LocalDate.of(2026, 5, 20));
146
+                Arrays.asList(vet, expert), Collections.emptyList(), YearMonth.of(2026, 7));
145 147
         assertEquals(2, vo.getAnnualVetCount());
146 148
         assertEquals(1, vo.getAnnualExpertCount());
147 149
         assertEquals(12, vo.getMonthlyStacked().size());
148 150
     }
149 151
 
152
+    @Test
153
+    @DisplayName("DP-SY-UT-011b 预约滚动 12 月堆叠")
154
+    void ut011b_rolling12MonthAppointment()
155
+    {
156
+        YearMonth anchor = YearMonth.of(2026, 7);
157
+        HomeScreenAppointmentStatDto row = new HomeScreenAppointmentStatDto();
158
+        row.setStatYear(2026);
159
+        row.setStatMonth(5);
160
+        row.setProviderType(1);
161
+        row.setCount(3);
162
+        HomeScreenAppointmentVo vo = HomeScreenSupport.buildAppointment(
163
+                Collections.emptyList(), Collections.singletonList(row), anchor);
164
+        assertEquals(12, vo.getMonthlyStacked().size());
165
+        assertEquals(2026, vo.getMonthlyStacked().get(9).getStatYear());
166
+        assertEquals(5, vo.getMonthlyStacked().get(9).getMonth());
167
+        assertEquals(3, vo.getMonthlyStacked().get(9).getVetCount());
168
+    }
169
+
150 170
     @Test
151 171
     @DisplayName("DP-SY-UT-013 补贴万元换算")
152 172
     void ut013_subsidyWan()
@@ -181,12 +201,31 @@ class HomeScreenSupportTest
181 201
         assertFalse(vo.isHasAgriData());
182 202
     }
183 203
 
204
+    @Test
205
+    @DisplayName("DP-SY-UT-017b 培训滚动 12 月")
206
+    void ut017b_rolling12MonthTraining()
207
+    {
208
+        YearMonth anchor = YearMonth.of(2026, 7);
209
+        HomeScreenTrainingMonthDto row = new HomeScreenTrainingMonthDto();
210
+        row.setStatYear(2026);
211
+        row.setStatMonth(3);
212
+        row.setActivityCount(1);
213
+        row.setEnrolledCount(20);
214
+        HomeScreenTrainingTrendVo vo = HomeScreenSupport.buildTrainingTrend(
215
+                Collections.singletonList(row), true, anchor);
216
+        assertEquals(12, vo.getMonthly().size());
217
+        assertEquals(2026, vo.getMonthly().get(7).getStatYear());
218
+        assertEquals(3, vo.getMonthly().get(7).getMonth());
219
+        assertEquals(1, vo.getMonthly().get(7).getActivityCount());
220
+        assertEquals(20, vo.getMonthly().get(7).getEnrolledCount());
221
+    }
222
+
184 223
     @Test
185 224
     @DisplayName("DP-SY-UT-018 无培训空态")
186 225
     void ut018_noTraining()
187 226
     {
188 227
         HomeScreenTrainingTrendVo vo = HomeScreenSupport.buildTrainingTrend(
189
-                Collections.emptyList(), false, LocalDate.of(2026, 5, 20));
228
+                Collections.emptyList(), false, YearMonth.of(2026, 5));
190 229
         assertFalse(vo.isHasTrainingData());
191 230
         assertEquals(12, vo.getMonthly().size());
192 231
     }

+ 6 - 4
doc/大屏/首页/大屏首页功能需求.md

@@ -39,7 +39,7 @@
39 39
 | 类型 | 适用区块 | 横轴规则 |
40 40
 | --- | --- | --- |
41 41
 | **统计年 Y 内 12 月** | 线下服务预约堆叠柱、实战培训报名趋势等 | 固定 **1 月~12 月** 共 12 个刻度,数据范围限定在 **Y 年** |
42
-| **滚动最近 12 个月** | **牦牛存栏变动**、**牦牛出栏统计** | 以服务器当前年月为锚点,取**含当月在内向前连续 12 个自然月**;**不**随页面「统计年份 **Y**」切换 |
42
+| **滚动最近 12 个月** | **牦牛存栏变动**、**牦牛出栏统计**、**线下服务预约**(堆叠柱)、**实战培训报名** | 以服务器当前年月为锚点,取**含当月在内向前连续 12 个自然月**;**不**随页面「统计年份 **Y**」切换 |
43 43
 
44 44
 **统计年 Y 内 12 月(预约、培训等)**
45 45
 
@@ -344,7 +344,8 @@ flowchart TB
344 344
 | 项 | 规则 |
345 345
 | --- | --- |
346 346
 | 左侧 | 三类**年度预约合计**卡片:兽医人员、专家人员、诊疗机构(**§2.7**) |
347
-| 右侧 | **堆叠柱状图**:横轴 12 月;系列为兽医/专家/机构预约人次 |
347
+| 右侧 | **堆叠柱状图**:**§2.3 滚动最近 12 月**;系列为兽医/专家/机构预约人次 |
348
+| 横轴 / 悬停 | 横轴**仅月份**;悬停显示 **YYYY年M月** 及三类人次 |
348 349
 | 图例 | 与三类名称一致,颜色区分 |
349 350
 
350 351
 ---
@@ -387,8 +388,9 @@ flowchart TB
387 388
 | --- | --- |
388 389
 | 图表类型 | 双系列**曲线/面积图**(以原型为准) |
389 390
 | 系列 | **活动数**(个)、**报名人数**(人) |
390
-| 横轴 | **§2.3** 1~12 月 |
391
-| 口径 | **§2.11** |
391
+| 横轴 | **§2.3 滚动最近 12 月** |
392
+| 横轴 / 悬停 | 横轴**仅月份**;悬停显示 **YYYY年M月** 及活动数、报名人数 |
393
+| 口径 | **§2.11**(窗口内 `training_time`) |
392 394
 
393 395
 ---
394 396
 

+ 14 - 11
doc/大屏/首页/大屏首页技术方案.md

@@ -22,8 +22,8 @@
22 22
 | 数据权限 | **全县**聚合:牦牛、牧场取全部 `biz_status=1` 且 `del_flag=0` 的牧场 ID(与疫病风险大屏一致,**不按**登录用户牧场过滤);草场全县 `del_flag=0` |
23 23
 | 统计日 | `Y = 当前年` → `statDate = 今日`;`Y` 为历史年 → `statDate = Y-12-31`(时点、月末还原、年出栏上界均据此) |
24 24
 | 主数据三项 | 供应商 / 承销商 / 交易市场**实时快照**,**不**随 `statYear` 变 |
25
-| 按月图表(预约、培训等) | 横轴固定 **12** 月(**Y** 年内);缺月、当前年未来月均为 **0** |
26
-| 滚动 12 月图表(存栏变动、出栏统计) | 锚点 = 服务器当前 `YearMonth`(`Asia/Shanghai`);窗口 = 锚点向前 11 月~锚点;固定 **12** 条;**不**随 `statYear` 变 |
25
+| 滚动 12 月图表 | 存栏变动、出栏统计、预约堆叠、培训趋势;锚点 = 当前 `YearMonth`;**不**随 `statYear` 变 |
26
+| 按月图表(Y 年内) | 已无(预约/培训已并入滚动 12 月) |
27 27
 | 性能 | 默认实时聚合;可选 **§2.3** 缓存表 |
28 28
 
29 29
 **依赖表(只读)**
@@ -285,7 +285,9 @@
285 285
 | 字段 | 说明 |
286 286
 | --- | --- |
287 287
 | `annualVetCount` / `annualExpertCount` / `annualOrgCount` | 三类年度合计 |
288
-| `monthlyStacked[]` | 12 条:`month`、`vetCount`、`expertCount`、`orgCount` |
288
+| `monthlyStacked[]` | 固定 **12** 条;滚动最近 12 自然月;**不**随 `statYear` 变 |
289
+
290
+`monthlyStacked[]`:`statYear`、`month`、`vetCount`、`expertCount`、`orgCount`(缺月各计数为 **0**)。
289 291
 
290 292
 #### 3.1.7 `subsidy`
291 293
 
@@ -307,10 +309,10 @@
307 309
 
308 310
 | 字段 | 说明 |
309 311
 | --- | --- |
310
-| `hasTrainingData` | **Y** 年是否存在已发布培训 |
311
-| `monthly[]` | 12 条:`month`、`activityCount`、`enrolledCount` |
312
+| `hasTrainingData` | 滚动 12 月窗口内是否存在已发布且 `training_time` 落在窗口内的培训 |
313
+| `monthly[]` | 固定 **12** 条;`statYear`、`month`、`activityCount`、`enrolledCount` |
312 314
 
313
-`hasTrainingData=false` 时 `monthly` 可为 `[]` 或全 0(前端以 `hasTrainingData` 走空态)。
315
+`hasTrainingData=false` 时 `monthly` 仍为 12 条全 0(前端以 `hasTrainingData` 走空态)。
314 316
 
315 317
 #### 响应示例(节选)
316 318
 
@@ -450,15 +452,15 @@
450 452
 
451 453
 **工程**:`ruoyi-screen/src/views/home/`(`index.vue`、`chartOptions.js`)。
452 454
 
453
-### 4.1 存栏变动 / 出栏统计图表
455
+### 4.1 滚动 12 月图表(存栏 / 出栏 / 预约 / 培训)
454 456
 
455 457
 | 项 | 实现 |
456 458
 | --- | --- |
457
-| 数据字段 | `inventoryChange.monthlyTrend[]`、`outboundMonthly[]`;按 `statYear`、`month` 升序 |
458
-| 横轴 | **仅月份**标签(`8月`);`buildInventoryTrendOption` / `buildOutboundOption` |
459
+| 数据字段 | `monthlyTrend[]`、`outboundMonthly[]`、`appointment.monthlyStacked[]`、`trainingTrend.monthly[]` |
460
+| 排序 | 按 `statYear`、`month` 升序 |
461
+| 横轴 | **仅月份**(`8月`);`chartOptions.js` |
459 462
 | Tooltip | **完整年月**(`2025年8月`)+ 数值 |
460
-| 出栏图 | **纯柱状图**,无趋势折线 |
461
-| 与 `statYear` | 切换年份**不**改变上述两图数据窗口 |
463
+| 与 `statYear` | 切换年份**不**改变上述图表数据窗口 |
462 464
 
463 465
 ---
464 466
 
@@ -487,5 +489,6 @@ SQL 示例:`sql/big_screen_home_perm.sql`(挂载「大屏」父菜单下)
487 489
 
488 490
 | 版本 | 日期 | 说明 |
489 491
 | --- | --- | --- |
492
+| 1.2 | 2026-07-04 | 预约堆叠、培训趋势改滚动 12 月 + statYear;前端横轴/Tooltip 与存栏出栏一致 |
490 493
 | 1.1 | 2026-07-04 | 存栏/出栏改填报表滚动 12 月;VO 增 `statYear`;年龄四档;地图/总览最近填报年月;§4 前端横轴/Tooltip |
491 494
 | 1.0 | 2026-05-20 | 初稿:无新表;双接口;对齐 `大屏首页功能需求.md` v1.0 |

+ 3 - 3
doc/大屏/首页/大屏首页测试用例.md

@@ -11,7 +11,7 @@
11 11
 | --- | --- |
12 12
 | 统计年 **Y** | 切换后全页一致;默认当前自然年 |
13 13
 | 统计日 | `Y`=当前年→今日;历史年→`Y-12-31` |
14
-| 按月图表(预约、培训) | 固定 12 月(**Y** 年内);缺月、当前年未来月=**0** |
14
+| 按月图表(预约、培训) | 已改为**滚动 12 月**(与存栏/出栏一致);含 `statYear`+`month`;**不**随 **Y** |
15 15
 | 滚动 12 月(存栏变动、出栏统计) | 锚点=当前年月;含 `statYear`+`month`;**不**随 **Y**;缺月=**0** |
16 16
 | 牦牛填报 | `biz_yak_herd_inventory` / `biz_yak_outbound_report` 全县合计;最近填报年月 |
17 17
 | 流通三项 | 供应商/承销商/交易市场**实时快照**,**不随 Y** |
@@ -143,10 +143,10 @@
143 143
 | DP-SY-UI-008 | 图表 | 出栏柱状 12 月 | UI 测试 | Playwright+Chrome | SY-06 | `OUT-R12` | 查看出栏统计+悬停 | 12 柱、**无趋势折线**;横轴仅月;悬停显年月+头数 |
144 144
 | DP-SY-UI-009 | 图表 | 草场可用/使用中 | UI 测试 | Playwright+Chrome | SY-07 | `GRASS-MIX` | 查看草场区上部 | 两数字区分「可用」「使用中」;不与退化表简单相加为总量 |
145 145
 | DP-SY-UI-010 | 图表 | 退化等级占比 | UI 测试 | Playwright+Chrome | SY-07 | 五档均有 | 查看退化表/环图 | 五档名称与 API 一致;占比为 % |
146
-| DP-SY-UI-011 | 图表 | 预约堆叠柱 | UI 测试 | Playwright+Chrome | SY-08 | `APT-Y` | 查看预约区 | 左侧三类年度卡片;右侧 12 月堆叠;图例兽医/专家/机构 |
146
+| DP-SY-UI-011 | 图表 | 预约堆叠柱 | UI 测试 | Playwright+Chrome | SY-08 | `APT-Y` | 查看预约区+悬停 | 左侧三类年度卡片;右侧滚动 12 月堆叠;横轴仅月;悬停显年月 |
147 147
 | DP-SY-UI-012 | 图表 | 补贴三类柱图 | UI 测试 | Playwright+Chrome | SY-09 | `SUB-Y` | 查看惠农补贴 | 横轴三类;纵轴万元;与 API 一致 |
148 148
 | DP-SY-UI-013 | 指标 | 农技课堂 | UI 测试 | Playwright+Chrome | SY-11 | `VIDEO-Y`+培训 | 查看农技课堂 | 视频数、培训场次、培训内容合计正确 |
149
-| DP-SY-UI-014 | 图表 | 培训双曲线 | UI 测试 | Playwright+Chrome | SY-12 | `TRAIN-PUB` | 查看培训报名趋势 | 活动数、报名人数两系列;12 月 |
149
+| DP-SY-UI-014 | 图表 | 培训双曲线 | UI 测试 | Playwright+Chrome | SY-12 | `TRAIN-PUB` | 查看培训报名趋势+悬停 | 活动数、报名人数两系列;横轴仅月;悬停显年月 |
150 150
 | DP-SY-UI-015 | 空态 | 无出栏 | UI 测试 | Playwright+Chrome | §7.1 | 选无出栏年 | 切换该年 | 出栏图空态或全 0;年出栏卡片为 0 |
151 151
 | DP-SY-UI-016 | 空态 | 无存栏趋势 | UI 测试 | Playwright+Chrome | §7.1 | `HERD-EMPTY` | 打开页 | 趋势图空态或全 0 折线;年龄结构空态 |
152 152
 | DP-SY-UI-017 | 空态 | 无预约 | UI 测试 | Playwright+Chrome | §7.1 | 无预约年 | 切换该年 | 预约区空态;三类年度卡片为 0 |