diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainRegHistoryStatController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainRegHistoryStatController.java new file mode 100644 index 000000000..6bd22d78f --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainRegHistoryStatController.java @@ -0,0 +1,110 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.train.domain.query.RegHistoryStatQuery; +import com.ruoyi.train.domain.vo.RegHistoryDetailVO; +import com.ruoyi.train.domain.vo.RegHistoryGroupVO; +import com.ruoyi.train.domain.vo.RegHistoryPageVO; +import com.ruoyi.train.service.ITrainRegHistoryStatService; + +/** + * 历史报到统计 Controller(独立模块)。 + * + *

路由前缀 /train/regdata/history-stat,与既有 {@code /train/regdata/history/**} 互不影响; + * 权限沿用既有报到查询/导出权限,不新增权限点,避免影响现有角色授权。

+ * + *

口径:报到状态取自 train_enrollment.enrollment_status,报到时间取自 join_time; + * 已报到 = status in (1,4) 且 join_time 非空;不使用 train_class_report。

+ * + * @author ruoyi + */ +@RestController +@RequestMapping("/train/regdata/history-stat") +public class TrainRegHistoryStatController extends BaseController +{ + @Autowired + private ITrainRegHistoryStatService trainRegHistoryStatService; + + /** + * 总览统计。 + * + *

GET /train/regdata/history-stat/overview

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/overview") + public AjaxResult overview(RegHistoryStatQuery query) + { + return success(trainRegHistoryStatService.selectOverview(query)); + } + + /** + * 分组汇总(batch/dept/major/place/gender/ageBand/dutyLevel/enrollSource/accommodation/firstTime)。 + * + *

GET /train/regdata/history-stat/groups?groupBy=dept&pageNum=1&pageSize=100

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/groups") + public TableDataInfo groups(RegHistoryStatQuery query) + { + RegHistoryPageVO page = trainRegHistoryStatService.selectGroupPage(query); + TableDataInfo table = getDataTable(page.getRows()); + table.setTotal(page.getTotal()); + return table; + } + + /** + * 报到时间分布(day/week/month/hour)。 + * + *

GET /train/regdata/history-stat/times?granularity=day

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/times") + public AjaxResult times(RegHistoryStatQuery query) + { + List buckets = trainRegHistoryStatService.selectTimeBuckets(query); + return success(buckets); + } + + /** + * 报到明细(分页,支持异常筛选)。 + * + *

GET /train/regdata/history-stat/details?exceptionType=all&pageNum=1&pageSize=10

+ */ + @PreAuthorize("@ss.hasPermi('train:report:list')") + @GetMapping("/details") + public TableDataInfo details(RegHistoryStatQuery query) + { + RegHistoryPageVO page = trainRegHistoryStatService.selectDetailPage(query); + TableDataInfo table = getDataTable(page.getRows()); + table.setTotal(page.getTotal()); + return table; + } + + /** + * 导出报到明细。 + * + *

POST /train/regdata/history-stat/details/export

+ */ + @PreAuthorize("@ss.hasPermi('train:report:export')") + @Log(title = "历史报到统计明细", businessType = BusinessType.EXPORT) + @PostMapping("/details/export") + public void exportDetails(HttpServletResponse response, RegHistoryStatQuery query) + { + List list = trainRegHistoryStatService.selectDetailList(query); + ExcelUtil util = new ExcelUtil(RegHistoryDetailVO.class); + util.exportExcel(response, list, "历史报到明细"); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/RegHistoryStatQuery.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/RegHistoryStatQuery.java new file mode 100644 index 000000000..2072bf3fc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/RegHistoryStatQuery.java @@ -0,0 +1,196 @@ +package com.ruoyi.train.domain.query; + +import java.util.Date; + +/** + * 历史报到统计查询条件。 + * + *

该对象仅服务于新增的历史报到统计(/train/regdata/history-stat), + * 与既有 {@code /train/regdata/history/*} 报表相互独立,不参与旧统计口径。

+ * + * @author ruoyi + */ +public class RegHistoryStatQuery +{ + /** 培训班ID(可空,空表示全部批次) */ + private String planId; + + /** 送培单位ID(train_student.dept_id) */ + private Long deptId; + + /** 专业代码(train_class_plan.class_major) */ + private Integer classMajor; + + /** 批次开班日期下界 yyyy-MM-dd */ + private String startDate; + + /** 批次开班日期上界 yyyy-MM-dd */ + private String endDate; + + /** 是否纳入未开始批次(默认 false,仅统计进行中/已结束批次) */ + private Boolean includeNotStarted = Boolean.FALSE; + + /** 分组维度:batch/dept/major/place/gender/ageBand/dutyLevel/enrollSource/accommodation/firstTime */ + private String groupBy = "batch"; + + /** 时间分布粒度:day/week/month/hour */ + private String granularity = "day"; + + /** 明细异常筛选:all/inconsistent/lagging/timeConflict/late/overdue/abnormal */ + private String exceptionType = "all"; + + /** 页码(内存分页,从 1 开始) */ + private Integer pageNum = 1; + + /** 每页条数 */ + private Integer pageSize = 10; + + /** 单次取数上限,防止全量拉取失控 */ + private Integer limit = 50000; + + /** 内部时间下界(由 Service 由 startDate 解析,不对外暴露) */ + private Date startTime; + + /** 内部时间上界(由 Service 由 endDate 解析,不对外暴露) */ + private Date endTime; + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public Long getDeptId() + { + return deptId; + } + + public void setDeptId(Long deptId) + { + this.deptId = deptId; + } + + public Integer getClassMajor() + { + return classMajor; + } + + public void setClassMajor(Integer classMajor) + { + this.classMajor = classMajor; + } + + public String getStartDate() + { + return startDate; + } + + public void setStartDate(String startDate) + { + this.startDate = startDate; + } + + public String getEndDate() + { + return endDate; + } + + public void setEndDate(String endDate) + { + this.endDate = endDate; + } + + public Boolean getIncludeNotStarted() + { + return includeNotStarted; + } + + public void setIncludeNotStarted(Boolean includeNotStarted) + { + this.includeNotStarted = includeNotStarted; + } + + public String getGroupBy() + { + return groupBy; + } + + public void setGroupBy(String groupBy) + { + this.groupBy = groupBy; + } + + public String getGranularity() + { + return granularity; + } + + public void setGranularity(String granularity) + { + this.granularity = granularity; + } + + public String getExceptionType() + { + return exceptionType; + } + + public void setExceptionType(String exceptionType) + { + this.exceptionType = exceptionType; + } + + public Integer getPageNum() + { + return pageNum; + } + + public void setPageNum(Integer pageNum) + { + this.pageNum = pageNum; + } + + public Integer getPageSize() + { + return pageSize; + } + + public void setPageSize(Integer pageSize) + { + this.pageSize = pageSize; + } + + public Integer getLimit() + { + return limit; + } + + public void setLimit(Integer limit) + { + this.limit = limit; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryDetailVO.java new file mode 100644 index 000000000..d167e2f1a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryDetailVO.java @@ -0,0 +1,396 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.annotation.Excel; + +/** + * 历史报到明细行。 + * + *

不含到训耗时、耗时分位、平均到训时刻;报到时间统一取 join_time。

+ * + * @author ruoyi + */ +public class RegHistoryDetailVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + @Excel(name = "参训档案ID") + private Long enrollmentId; + + @Excel(name = "培训班ID") + private String planId; + + @Excel(name = "班级名称") + private String className; + + @Excel(name = "开班名称") + private String planName; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "开班时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "报到开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date reportStartTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "报到截止时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date reportDeadline; + + private String studentId; + + @Excel(name = "学员姓名") + private String studentName; + + @Excel(name = "人员编号") + private String personNo; + + @Excel(name = "性别") + private String gender; + + @Excel(name = "年龄段") + private String ageBand; + + private Long deptId; + + @Excel(name = "送培单位") + private String deptName; + + private Integer enrollmentStatus; + + @Excel(name = "参训状态") + private String statusText; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "报到时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date joinTime; + + @Excel(name = "报到日期") + private String reportDate; + + @Excel(name = "报到时段") + private String reportHour; + + @Excel(name = "报到情况") + private String lateText; + + @Excel(name = "异常标记") + private String exceptionText; + + /** 是否已报到 */ + private Boolean reportedFlag = Boolean.FALSE; + + /** 是否迟到 */ + private Boolean lateFlag = Boolean.FALSE; + + /** 迟到分钟数 */ + private Integer lateMinutes = 0; + + /** 状态时间不一致 */ + private Boolean inconsistentFlag = Boolean.FALSE; + + /** 状态滞后(待报到但有报到时间) */ + private Boolean laggingFlag = Boolean.FALSE; + + /** 时间倒挂或未来时间 */ + private Boolean timeConflictFlag = Boolean.FALSE; + + /** 逾期未报到 */ + private Boolean overdueFlag = Boolean.FALSE; + + /** 是否存在数据质量异常 */ + private Boolean abnormalFlag = Boolean.FALSE; + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getClassName() + { + return className; + } + + public void setClassName(String className) + { + this.className = className; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getReportStartTime() + { + return reportStartTime; + } + + public void setReportStartTime(Date reportStartTime) + { + this.reportStartTime = reportStartTime; + } + + public Date getReportDeadline() + { + return reportDeadline; + } + + public void setReportDeadline(Date reportDeadline) + { + this.reportDeadline = reportDeadline; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getPersonNo() + { + return personNo; + } + + public void setPersonNo(String personNo) + { + this.personNo = personNo; + } + + public String getGender() + { + return gender; + } + + public void setGender(String gender) + { + this.gender = gender; + } + + public String getAgeBand() + { + return ageBand; + } + + public void setAgeBand(String ageBand) + { + this.ageBand = ageBand; + } + + public Long getDeptId() + { + return deptId; + } + + public void setDeptId(Long deptId) + { + this.deptId = deptId; + } + + public String getDeptName() + { + return deptName; + } + + public void setDeptName(String deptName) + { + this.deptName = deptName; + } + + public Integer getEnrollmentStatus() + { + return enrollmentStatus; + } + + public void setEnrollmentStatus(Integer enrollmentStatus) + { + this.enrollmentStatus = enrollmentStatus; + } + + public String getStatusText() + { + return statusText; + } + + public void setStatusText(String statusText) + { + this.statusText = statusText; + } + + public Date getJoinTime() + { + return joinTime; + } + + public void setJoinTime(Date joinTime) + { + this.joinTime = joinTime; + } + + public String getReportDate() + { + return reportDate; + } + + public void setReportDate(String reportDate) + { + this.reportDate = reportDate; + } + + public String getReportHour() + { + return reportHour; + } + + public void setReportHour(String reportHour) + { + this.reportHour = reportHour; + } + + public String getLateText() + { + return lateText; + } + + public void setLateText(String lateText) + { + this.lateText = lateText; + } + + public String getExceptionText() + { + return exceptionText; + } + + public void setExceptionText(String exceptionText) + { + this.exceptionText = exceptionText; + } + + public Boolean getReportedFlag() + { + return reportedFlag; + } + + public void setReportedFlag(Boolean reportedFlag) + { + this.reportedFlag = reportedFlag; + } + + public Boolean getLateFlag() + { + return lateFlag; + } + + public void setLateFlag(Boolean lateFlag) + { + this.lateFlag = lateFlag; + } + + public Integer getLateMinutes() + { + return lateMinutes; + } + + public void setLateMinutes(Integer lateMinutes) + { + this.lateMinutes = lateMinutes; + } + + public Boolean getInconsistentFlag() + { + return inconsistentFlag; + } + + public void setInconsistentFlag(Boolean inconsistentFlag) + { + this.inconsistentFlag = inconsistentFlag; + } + + public Boolean getLaggingFlag() + { + return laggingFlag; + } + + public void setLaggingFlag(Boolean laggingFlag) + { + this.laggingFlag = laggingFlag; + } + + public Boolean getTimeConflictFlag() + { + return timeConflictFlag; + } + + public void setTimeConflictFlag(Boolean timeConflictFlag) + { + this.timeConflictFlag = timeConflictFlag; + } + + public Boolean getOverdueFlag() + { + return overdueFlag; + } + + public void setOverdueFlag(Boolean overdueFlag) + { + this.overdueFlag = overdueFlag; + } + + public Boolean getAbnormalFlag() + { + return abnormalFlag; + } + + public void setAbnormalFlag(Boolean abnormalFlag) + { + this.abnormalFlag = abnormalFlag; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryFactVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryFactVO.java new file mode 100644 index 000000000..f5599c23d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryFactVO.java @@ -0,0 +1,428 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 历史报到统计事实行(只读投影)。 + * + *

数据来源严格限定为 train_enrollment + train_class_plan + train_student + sys_dept, + * 不引用 train_class_report,报到状态取自 enrollment_status,报到时间取自 join_time。

+ * + * @author ruoyi + */ +public class RegHistoryFactVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 参训档案ID */ + private Long enrollmentId; + + /** 培训班ID */ + private String planId; + + /** 班级名称(回退开班名称) */ + private String className; + + /** 开班名称 */ + private String planName; + + /** 批次状态 0未开始 1进行中 2已结束 3已取消 */ + private Integer planStatus; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + /** 计划报到开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportStartTime; + + /** 计划报到截止时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportDeadline; + + /** 批次创建时间(用于识别报到时间早于计划创建) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date planCreateTime; + + /** 所属专业 */ + private Integer classMajor; + + /** 培训地点 */ + private String trainAddress; + + /** 班主任 */ + private String classTeacher; + + /** 主办部门ID */ + private Integer hostDeptId; + + /** 负责人 */ + private String manager; + + /** 学员ID */ + private String studentId; + + /** 学员姓名 */ + private String studentName; + + /** 人员编号 */ + private String personNo; + + /** 性别 */ + private String gender; + + /** 出生日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date birth; + + /** 职级 */ + private Integer dutyLevel; + + /** 送培单位ID */ + private Long deptId; + + /** 送培单位名称 */ + private String deptName; + + /** 学员主档状态 */ + private Integer studentStatus; + + /** 学员主档删除标志 */ + private String studentDelFlag; + + /** 参训名单来源 */ + private Integer enrollSource; + + /** 是否需住宿 0否 1是 */ + private Integer accommodationRequired; + + /** 参训状态 0待报到 1已报到 2请假 3放弃 4已完成 */ + private Integer enrollmentStatus; + + /** 入班报到时间(唯一报到时间来源) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date joinTime; + + /** 参训档案创建时间(用于首次参训判定) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date enrollCreateTime; + + /** 参训档案更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getClassName() + { + return className; + } + + public void setClassName(String className) + { + this.className = className; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public Integer getPlanStatus() + { + return planStatus; + } + + public void setPlanStatus(Integer planStatus) + { + this.planStatus = planStatus; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } + + public Date getReportStartTime() + { + return reportStartTime; + } + + public void setReportStartTime(Date reportStartTime) + { + this.reportStartTime = reportStartTime; + } + + public Date getReportDeadline() + { + return reportDeadline; + } + + public void setReportDeadline(Date reportDeadline) + { + this.reportDeadline = reportDeadline; + } + + public Date getPlanCreateTime() + { + return planCreateTime; + } + + public void setPlanCreateTime(Date planCreateTime) + { + this.planCreateTime = planCreateTime; + } + + public Integer getClassMajor() + { + return classMajor; + } + + public void setClassMajor(Integer classMajor) + { + this.classMajor = classMajor; + } + + public String getTrainAddress() + { + return trainAddress; + } + + public void setTrainAddress(String trainAddress) + { + this.trainAddress = trainAddress; + } + + public String getClassTeacher() + { + return classTeacher; + } + + public void setClassTeacher(String classTeacher) + { + this.classTeacher = classTeacher; + } + + public Integer getHostDeptId() + { + return hostDeptId; + } + + public void setHostDeptId(Integer hostDeptId) + { + this.hostDeptId = hostDeptId; + } + + public String getManager() + { + return manager; + } + + public void setManager(String manager) + { + this.manager = manager; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getPersonNo() + { + return personNo; + } + + public void setPersonNo(String personNo) + { + this.personNo = personNo; + } + + public String getGender() + { + return gender; + } + + public void setGender(String gender) + { + this.gender = gender; + } + + public Date getBirth() + { + return birth; + } + + public void setBirth(Date birth) + { + this.birth = birth; + } + + public Integer getDutyLevel() + { + return dutyLevel; + } + + public void setDutyLevel(Integer dutyLevel) + { + this.dutyLevel = dutyLevel; + } + + public Long getDeptId() + { + return deptId; + } + + public void setDeptId(Long deptId) + { + this.deptId = deptId; + } + + public String getDeptName() + { + return deptName; + } + + public void setDeptName(String deptName) + { + this.deptName = deptName; + } + + public Integer getStudentStatus() + { + return studentStatus; + } + + public void setStudentStatus(Integer studentStatus) + { + this.studentStatus = studentStatus; + } + + public String getStudentDelFlag() + { + return studentDelFlag; + } + + public void setStudentDelFlag(String studentDelFlag) + { + this.studentDelFlag = studentDelFlag; + } + + public Integer getEnrollSource() + { + return enrollSource; + } + + public void setEnrollSource(Integer enrollSource) + { + this.enrollSource = enrollSource; + } + + public Integer getAccommodationRequired() + { + return accommodationRequired; + } + + public void setAccommodationRequired(Integer accommodationRequired) + { + this.accommodationRequired = accommodationRequired; + } + + public Integer getEnrollmentStatus() + { + return enrollmentStatus; + } + + public void setEnrollmentStatus(Integer enrollmentStatus) + { + this.enrollmentStatus = enrollmentStatus; + } + + public Date getJoinTime() + { + return joinTime; + } + + public void setJoinTime(Date joinTime) + { + this.joinTime = joinTime; + } + + public Date getEnrollCreateTime() + { + return enrollCreateTime; + } + + public void setEnrollCreateTime(Date enrollCreateTime) + { + this.enrollCreateTime = enrollCreateTime; + } + + public Date getUpdateTime() + { + return updateTime; + } + + public void setUpdateTime(Date updateTime) + { + this.updateTime = updateTime; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryGroupVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryGroupVO.java new file mode 100644 index 000000000..fe24f726c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryGroupVO.java @@ -0,0 +1,301 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; + +/** + * 历史报到统计分组汇总行。 + * + *

不含到训耗时、耗时分位、平均到训时刻三类指标。

+ * + * @author ruoyi + */ +public class RegHistoryGroupVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 分组键(部门ID、批次ID、属性取值等) */ + private String groupKey; + + /** 分组展示名 */ + private String groupLabel; + + /** 覆盖批次数 */ + private Integer batchCount = 0; + + /** 应报到人次 */ + private Integer expectedCount = 0; + + /** 已报到人次 */ + private Integer reportedCount = 0; + + /** 已报到去重人数 */ + private Integer reportedPersonCount = 0; + + /** 报到率(%) */ + private Double reportRate = 0D; + + /** 出勤报到率(%)(请假不计入分母) */ + private Double attendanceRate = 0D; + + /** 待报到人次 */ + private Integer waitingCount = 0; + + /** 请假人次 */ + private Integer leaveCount = 0; + + /** 状态时间不一致人次 */ + private Integer inconsistentCount = 0; + + /** 迟到人次 */ + private Integer lateCount = 0; + + /** 迟到率(%) */ + private Double lateRate = 0D; + + /** 准点率(%) */ + private Double onTimeRate = 0D; + + /** 是否达到目标报到率(仅批次分组有效) */ + private Boolean targetMet; + + /** 批次内首位报到时间(仅批次分组有效) */ + private String firstArriveTime; + + /** 批次内末位报到时间(仅批次分组有效) */ + private String lastArriveTime; + + /** 批次状态(仅批次分组有效) */ + private Integer planStatus; + + /** 与全局报到率的偏差(百分点) */ + private Double gapToGlobal; + + /** 单位离群标记:|偏差| >= 20 个百分点且应报到 >= 10 */ + private Boolean outlierFlag = Boolean.FALSE; + + /** 集中报到标记:单小时报到人数 >= max(10, 已报到 × 50%) */ + private Boolean concentratedFlag = Boolean.FALSE; + + /** 报到率波动标记:与最近 6 期均值偏差 >= 10 个百分点(仅批次分组有效) */ + private Boolean volatilityFlag = Boolean.FALSE; + + public String getGroupKey() + { + return groupKey; + } + + public void setGroupKey(String groupKey) + { + this.groupKey = groupKey; + } + + public String getGroupLabel() + { + return groupLabel; + } + + public void setGroupLabel(String groupLabel) + { + this.groupLabel = groupLabel; + } + + public Integer getBatchCount() + { + return batchCount; + } + + public void setBatchCount(Integer batchCount) + { + this.batchCount = batchCount; + } + + public Integer getExpectedCount() + { + return expectedCount; + } + + public void setExpectedCount(Integer expectedCount) + { + this.expectedCount = expectedCount; + } + + public Integer getReportedCount() + { + return reportedCount; + } + + public void setReportedCount(Integer reportedCount) + { + this.reportedCount = reportedCount; + } + + public Integer getReportedPersonCount() + { + return reportedPersonCount; + } + + public void setReportedPersonCount(Integer reportedPersonCount) + { + this.reportedPersonCount = reportedPersonCount; + } + + public Double getReportRate() + { + return reportRate; + } + + public void setReportRate(Double reportRate) + { + this.reportRate = reportRate; + } + + public Double getAttendanceRate() + { + return attendanceRate; + } + + public void setAttendanceRate(Double attendanceRate) + { + this.attendanceRate = attendanceRate; + } + + public Integer getWaitingCount() + { + return waitingCount; + } + + public void setWaitingCount(Integer waitingCount) + { + this.waitingCount = waitingCount; + } + + public Integer getLeaveCount() + { + return leaveCount; + } + + public void setLeaveCount(Integer leaveCount) + { + this.leaveCount = leaveCount; + } + + public Integer getInconsistentCount() + { + return inconsistentCount; + } + + public void setInconsistentCount(Integer inconsistentCount) + { + this.inconsistentCount = inconsistentCount; + } + + public Integer getLateCount() + { + return lateCount; + } + + public void setLateCount(Integer lateCount) + { + this.lateCount = lateCount; + } + + public Double getLateRate() + { + return lateRate; + } + + public void setLateRate(Double lateRate) + { + this.lateRate = lateRate; + } + + public Double getOnTimeRate() + { + return onTimeRate; + } + + public void setOnTimeRate(Double onTimeRate) + { + this.onTimeRate = onTimeRate; + } + + public Boolean getTargetMet() + { + return targetMet; + } + + public void setTargetMet(Boolean targetMet) + { + this.targetMet = targetMet; + } + + public String getFirstArriveTime() + { + return firstArriveTime; + } + + public void setFirstArriveTime(String firstArriveTime) + { + this.firstArriveTime = firstArriveTime; + } + + public String getLastArriveTime() + { + return lastArriveTime; + } + + public void setLastArriveTime(String lastArriveTime) + { + this.lastArriveTime = lastArriveTime; + } + + public Integer getPlanStatus() + { + return planStatus; + } + + public void setPlanStatus(Integer planStatus) + { + this.planStatus = planStatus; + } + + public Double getGapToGlobal() + { + return gapToGlobal; + } + + public void setGapToGlobal(Double gapToGlobal) + { + this.gapToGlobal = gapToGlobal; + } + + public Boolean getOutlierFlag() + { + return outlierFlag; + } + + public void setOutlierFlag(Boolean outlierFlag) + { + this.outlierFlag = outlierFlag; + } + + public Boolean getConcentratedFlag() + { + return concentratedFlag; + } + + public void setConcentratedFlag(Boolean concentratedFlag) + { + this.concentratedFlag = concentratedFlag; + } + + public Boolean getVolatilityFlag() + { + return volatilityFlag; + } + + public void setVolatilityFlag(Boolean volatilityFlag) + { + this.volatilityFlag = volatilityFlag; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryOverviewVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryOverviewVO.java new file mode 100644 index 000000000..75a95fdea --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryOverviewVO.java @@ -0,0 +1,237 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; + +/** + * 历史报到统计总览(不含到训耗时类指标)。 + * + *

口径:已报到 = enrollment_status in (1,4) 且 join_time 非空; + * 应报到 = enrollment_status in (0,1,2,4)。

+ * + * @author ruoyi + */ +public class RegHistoryOverviewVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 参与统计的批次数 */ + private Integer batchCount = 0; + + /** 应报到人次 */ + private Integer expectedCount = 0; + + /** 已报到人次 */ + private Integer reportedCount = 0; + + /** 已报到去重人数(按 student_id) */ + private Integer reportedPersonCount = 0; + + /** 报到率(%) = 已报到 / 应报到 */ + private Double reportRate = 0D; + + /** 出勤报到率(%) = 已报到 / (待报到+已报到+已完成),请假不计入分母 */ + private Double attendanceRate = 0D; + + /** 待报到人次 */ + private Integer waitingCount = 0; + + /** 逾期未报到人次(status=0 且已超过报到截止基准) */ + private Integer waitingOverdueCount = 0; + + /** 请假人次 */ + private Integer leaveCount = 0; + + /** 状态与时间不一致人次(status in (1,4) 但 join_time 为空) */ + private Integer inconsistentCount = 0; + + /** 迟到人次 */ + private Integer lateCount = 0; + + /** 迟到率(%) = 迟到 / 已报到 */ + private Double lateRate = 0D; + + /** 准点率(%) = (已报到 - 迟到) / 已报到 */ + private Double onTimeRate = 0D; + + /** 数据质量异常人次(状态时间不一致、状态滞后、时间倒挂、未来时间等) */ + private Integer abnormalCount = 0; + + /** 因同批次同人重复档案被合并的记录数 */ + private Integer duplicateArchiveCount = 0; + + /** 目标报到率(%) */ + private Double targetRate = 0D; + + /** 达标批次数(批次报到率 >= 目标) */ + private Integer targetMetBatchCount = 0; + + public Integer getBatchCount() + { + return batchCount; + } + + public void setBatchCount(Integer batchCount) + { + this.batchCount = batchCount; + } + + public Integer getExpectedCount() + { + return expectedCount; + } + + public void setExpectedCount(Integer expectedCount) + { + this.expectedCount = expectedCount; + } + + public Integer getReportedCount() + { + return reportedCount; + } + + public void setReportedCount(Integer reportedCount) + { + this.reportedCount = reportedCount; + } + + public Integer getReportedPersonCount() + { + return reportedPersonCount; + } + + public void setReportedPersonCount(Integer reportedPersonCount) + { + this.reportedPersonCount = reportedPersonCount; + } + + public Double getReportRate() + { + return reportRate; + } + + public void setReportRate(Double reportRate) + { + this.reportRate = reportRate; + } + + public Double getAttendanceRate() + { + return attendanceRate; + } + + public void setAttendanceRate(Double attendanceRate) + { + this.attendanceRate = attendanceRate; + } + + public Integer getWaitingCount() + { + return waitingCount; + } + + public void setWaitingCount(Integer waitingCount) + { + this.waitingCount = waitingCount; + } + + public Integer getWaitingOverdueCount() + { + return waitingOverdueCount; + } + + public void setWaitingOverdueCount(Integer waitingOverdueCount) + { + this.waitingOverdueCount = waitingOverdueCount; + } + + public Integer getLeaveCount() + { + return leaveCount; + } + + public void setLeaveCount(Integer leaveCount) + { + this.leaveCount = leaveCount; + } + + public Integer getInconsistentCount() + { + return inconsistentCount; + } + + public void setInconsistentCount(Integer inconsistentCount) + { + this.inconsistentCount = inconsistentCount; + } + + public Integer getLateCount() + { + return lateCount; + } + + public void setLateCount(Integer lateCount) + { + this.lateCount = lateCount; + } + + public Double getLateRate() + { + return lateRate; + } + + public void setLateRate(Double lateRate) + { + this.lateRate = lateRate; + } + + public Double getOnTimeRate() + { + return onTimeRate; + } + + public void setOnTimeRate(Double onTimeRate) + { + this.onTimeRate = onTimeRate; + } + + public Integer getAbnormalCount() + { + return abnormalCount; + } + + public void setAbnormalCount(Integer abnormalCount) + { + this.abnormalCount = abnormalCount; + } + + public Integer getDuplicateArchiveCount() + { + return duplicateArchiveCount; + } + + public void setDuplicateArchiveCount(Integer duplicateArchiveCount) + { + this.duplicateArchiveCount = duplicateArchiveCount; + } + + public Double getTargetRate() + { + return targetRate; + } + + public void setTargetRate(Double targetRate) + { + this.targetRate = targetRate; + } + + public Integer getTargetMetBatchCount() + { + return targetMetBatchCount; + } + + public void setTargetMetBatchCount(Integer targetMetBatchCount) + { + this.targetMetBatchCount = targetMetBatchCount; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryPageVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryPageVO.java new file mode 100644 index 000000000..083a9a8ce --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryPageVO.java @@ -0,0 +1,54 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 内存分页结果(用于历史报到统计的分组表与明细表)。 + * + *

统计口径要求先全量聚合再分页,因此不复用 PageHelper 的 SQL 分页。

+ * + * @param 行类型 + * @author ruoyi + */ +public class RegHistoryPageVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 当前页数据 */ + private List rows = new ArrayList<>(); + + /** 总条数 */ + private long total = 0L; + + public RegHistoryPageVO() + { + } + + public RegHistoryPageVO(List rows, long total) + { + this.rows = rows; + this.total = total; + } + + public List getRows() + { + return rows; + } + + public void setRows(List rows) + { + this.rows = rows; + } + + public long getTotal() + { + return total; + } + + public void setTotal(long total) + { + this.total = total; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryTimeBucketVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryTimeBucketVO.java new file mode 100644 index 000000000..e6134a5c6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/RegHistoryTimeBucketVO.java @@ -0,0 +1,104 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; + +/** + * 历史报到时间分布桶(仅统计已报到记录)。 + * + * @author ruoyi + */ +public class RegHistoryTimeBucketVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 粒度:day/week/month/hour */ + private String granularity; + + /** 区间键:2026-11-10 / 2026-W45 / 2026-11 / 08 */ + private String periodKey; + + /** 区间展示文本 */ + private String periodLabel; + + /** 报到人数 */ + private Integer reportedCount = 0; + + /** 占已报到总数比例(%) */ + private Double share = 0D; + + /** 累计占比(%) */ + private Double cumulativeShare = 0D; + + /** 是否为峰值桶 */ + private Boolean peak = Boolean.FALSE; + + public String getGranularity() + { + return granularity; + } + + public void setGranularity(String granularity) + { + this.granularity = granularity; + } + + public String getPeriodKey() + { + return periodKey; + } + + public void setPeriodKey(String periodKey) + { + this.periodKey = periodKey; + } + + public String getPeriodLabel() + { + return periodLabel; + } + + public void setPeriodLabel(String periodLabel) + { + this.periodLabel = periodLabel; + } + + public Integer getReportedCount() + { + return reportedCount; + } + + public void setReportedCount(Integer reportedCount) + { + this.reportedCount = reportedCount; + } + + public Double getShare() + { + return share; + } + + public void setShare(Double share) + { + this.share = share; + } + + public Double getCumulativeShare() + { + return cumulativeShare; + } + + public void setCumulativeShare(Double cumulativeShare) + { + this.cumulativeShare = cumulativeShare; + } + + public Boolean getPeak() + { + return peak; + } + + public void setPeak(Boolean peak) + { + this.peak = peak; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainRegHistoryStatMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainRegHistoryStatMapper.java new file mode 100644 index 000000000..b2f16f232 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainRegHistoryStatMapper.java @@ -0,0 +1,25 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.query.RegHistoryStatQuery; +import com.ruoyi.train.domain.vo.RegHistoryFactVO; + +/** + * 历史报到统计只读 Mapper。 + * + *

仅查询 train_enrollment / train_class_plan / train_student / sys_dept, + * 不访问 train_class_report,也不写入任何表。

+ * + * @author ruoyi + */ +public interface TrainRegHistoryStatMapper +{ + /** + * 查询历史报到统计事实行(扁平投影,聚合在 Java 侧完成)。 + * + * @param query 过滤条件 + * @return 事实行集合 + */ + List selectHistoryFacts(@Param("q") RegHistoryStatQuery query); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainRegHistoryStatService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainRegHistoryStatService.java new file mode 100644 index 000000000..5eaa7929c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainRegHistoryStatService.java @@ -0,0 +1,60 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.query.RegHistoryStatQuery; +import com.ruoyi.train.domain.vo.RegHistoryDetailVO; +import com.ruoyi.train.domain.vo.RegHistoryGroupVO; +import com.ruoyi.train.domain.vo.RegHistoryOverviewVO; +import com.ruoyi.train.domain.vo.RegHistoryPageVO; +import com.ruoyi.train.domain.vo.RegHistoryTimeBucketVO; + +/** + * 历史报到统计服务(独立于既有报到统计)。 + * + *

统计口径:已报到 = enrollment_status in (1,4) 且 join_time 非空; + * 不使用 train_class_report;不计算耗时类指标。

+ * + * @author ruoyi + */ +public interface ITrainRegHistoryStatService +{ + /** + * 总览统计。 + * + * @param query 过滤条件 + * @return 总览 + */ + RegHistoryOverviewVO selectOverview(RegHistoryStatQuery query); + + /** + * 分组汇总(内存分页)。 + * + * @param query 过滤条件与分组维度 + * @return 分组行分页结果 + */ + RegHistoryPageVO selectGroupPage(RegHistoryStatQuery query); + + /** + * 报到时间分布。 + * + * @param query 过滤条件与时间粒度 + * @return 时间桶列表 + */ + List selectTimeBuckets(RegHistoryStatQuery query); + + /** + * 报到明细(内存分页)。 + * + * @param query 过滤条件与异常筛选 + * @return 明细分页结果 + */ + RegHistoryPageVO selectDetailPage(RegHistoryStatQuery query); + + /** + * 报到明细全量(导出用,不分页)。 + * + * @param query 过滤条件与异常筛选 + * @return 明细列表 + */ + List selectDetailList(RegHistoryStatQuery query); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainRegHistoryStatServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainRegHistoryStatServiceImpl.java new file mode 100644 index 000000000..233c270dd --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainRegHistoryStatServiceImpl.java @@ -0,0 +1,235 @@ +package com.ruoyi.train.service.impl; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.train.domain.query.RegHistoryStatQuery; +import com.ruoyi.train.domain.vo.RegHistoryDetailVO; +import com.ruoyi.train.domain.vo.RegHistoryFactVO; +import com.ruoyi.train.domain.vo.RegHistoryGroupVO; +import com.ruoyi.train.domain.vo.RegHistoryOverviewVO; +import com.ruoyi.train.domain.vo.RegHistoryPageVO; +import com.ruoyi.train.domain.vo.RegHistoryTimeBucketVO; +import com.ruoyi.train.mapper.TrainRegHistoryStatMapper; +import com.ruoyi.train.service.ITrainRegHistoryStatService; +import com.ruoyi.train.support.RegHistoryStatCalculator; + +/** + * 历史报到统计服务实现。 + * + *

与既有 {@code TrainRegDataServiceImpl} 完全隔离:独立 Mapper、独立口径、独立接口, + * 不修改任何既有统计方法的返回值。

+ * + * @author ruoyi + */ +@Service +public class TrainRegHistoryStatServiceImpl implements ITrainRegHistoryStatService +{ + /** 单次取数上限 */ + private static final int MAX_LIMIT = 50000; + + /** 单页上限 */ + private static final int MAX_PAGE_SIZE = 200; + + /** 默认页大小 */ + private static final int DEFAULT_PAGE_SIZE = 10; + + @Autowired + private TrainRegHistoryStatMapper trainRegHistoryStatMapper; + + @Override + public RegHistoryOverviewVO selectOverview(RegHistoryStatQuery query) + { + RegHistoryStatQuery normalized = normalize(query); + RegHistoryStatCalculator.Dataset dataset = loadDataset(normalized); + RegHistoryOverviewVO overview = RegHistoryStatCalculator.buildOverview(dataset.getRows(), + RegHistoryStatCalculator.DEFAULT_TARGET_RATE); + overview.setDuplicateArchiveCount(dataset.getDuplicateRemovedCount()); + return overview; + } + + @Override + public RegHistoryPageVO selectGroupPage(RegHistoryStatQuery query) + { + RegHistoryStatQuery normalized = normalize(query); + RegHistoryStatCalculator.Dataset dataset = loadDataset(normalized); + List groups = RegHistoryStatCalculator.buildGroups(dataset.getRows(), + normalized.getGroupBy(), RegHistoryStatCalculator.DEFAULT_TARGET_RATE); + return paginate(groups, normalized.getPageNum(), normalized.getPageSize()); + } + + @Override + public List selectTimeBuckets(RegHistoryStatQuery query) + { + RegHistoryStatQuery normalized = normalize(query); + RegHistoryStatCalculator.Dataset dataset = loadDataset(normalized); + return RegHistoryStatCalculator.buildTimeBuckets(dataset.getRows(), normalized.getGranularity()); + } + + @Override + public RegHistoryPageVO selectDetailPage(RegHistoryStatQuery query) + { + RegHistoryStatQuery normalized = normalize(query); + List details = filterDetails(buildDetails(normalized), normalized.getExceptionType()); + return paginate(details, normalized.getPageNum(), normalized.getPageSize()); + } + + @Override + public List selectDetailList(RegHistoryStatQuery query) + { + RegHistoryStatQuery normalized = normalize(query); + return filterDetails(buildDetails(normalized), normalized.getExceptionType()); + } + + private List buildDetails(RegHistoryStatQuery query) + { + RegHistoryStatCalculator.Dataset dataset = loadDataset(query); + return RegHistoryStatCalculator.buildDetails(dataset.getRows()); + } + + /** + * 查询事实行并完成过滤/去重。每次请求独立取数,不缓存、不共享状态。 + */ + private RegHistoryStatCalculator.Dataset loadDataset(RegHistoryStatQuery query) + { + List facts = trainRegHistoryStatMapper.selectHistoryFacts(query); + return RegHistoryStatCalculator.prepare(facts); + } + + /** + * 参数归一化:白名单校验 + 分页/取数上限收口 + 日期解析。 + */ + private RegHistoryStatQuery normalize(RegHistoryStatQuery source) + { + RegHistoryStatQuery query = source == null ? new RegHistoryStatQuery() : source; + query.setGroupBy(RegHistoryStatCalculator.normalizeGroupBy(query.getGroupBy())); + query.setGranularity(RegHistoryStatCalculator.normalizeGranularity(query.getGranularity())); + + Integer pageNum = query.getPageNum(); + query.setPageNum(pageNum == null || pageNum < 1 ? 1 : pageNum); + Integer pageSize = query.getPageSize(); + if (pageSize == null || pageSize < 1) + { + query.setPageSize(DEFAULT_PAGE_SIZE); + } + else if (pageSize > MAX_PAGE_SIZE) + { + query.setPageSize(MAX_PAGE_SIZE); + } + Integer limit = query.getLimit(); + if (limit == null || limit < 1 || limit > MAX_LIMIT) + { + query.setLimit(MAX_LIMIT); + } + query.setStartTime(parseStart(query.getStartDate())); + query.setEndTime(parseEnd(query.getEndDate())); + return query; + } + + private Date parseStart(String text) + { + LocalDate date = parseDate(text); + if (date == null) + { + return null; + } + return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant()); + } + + private Date parseEnd(String text) + { + LocalDate date = parseDate(text); + if (date == null) + { + return null; + } + return Date.from(date.atTime(23, 59, 59).atZone(ZoneId.systemDefault()).toInstant()); + } + + private LocalDate parseDate(String text) + { + if (text == null || text.trim().isEmpty()) + { + return null; + } + try + { + return LocalDate.parse(text.trim().substring(0, Math.min(10, text.trim().length()))); + } + catch (RuntimeException ex) + { + return null; + } + } + + /** + * 明细异常筛选:all/inconsistent/lagging/timeConflict/late/overdue/abnormal。 + */ + private List filterDetails(List details, String exceptionType) + { + if (details == null || details.isEmpty()) + { + return new ArrayList<>(); + } + if (exceptionType == null || exceptionType.trim().isEmpty() + || "all".equalsIgnoreCase(exceptionType.trim())) + { + return details; + } + String type = exceptionType.trim(); + List filtered = new ArrayList<>(); + for (RegHistoryDetailVO detail : details) + { + if (matches(detail, type)) + { + filtered.add(detail); + } + } + return filtered; + } + + private boolean matches(RegHistoryDetailVO detail, String type) + { + switch (type) + { + case "inconsistent": + return Boolean.TRUE.equals(detail.getInconsistentFlag()); + case "lagging": + return Boolean.TRUE.equals(detail.getLaggingFlag()); + case "timeConflict": + return Boolean.TRUE.equals(detail.getTimeConflictFlag()); + case "late": + return Boolean.TRUE.equals(detail.getLateFlag()); + case "overdue": + return Boolean.TRUE.equals(detail.getOverdueFlag()); + case "abnormal": + return Boolean.TRUE.equals(detail.getAbnormalFlag()) + || Boolean.TRUE.equals(detail.getOverdueFlag()); + default: + return true; + } + } + + /** + * 内存分页(不改变全量聚合口径)。 + */ + private RegHistoryPageVO paginate(List all, int pageNum, int pageSize) + { + int total = all == null ? 0 : all.size(); + if (total == 0) + { + return new RegHistoryPageVO<>(new ArrayList(), 0L); + } + int from = (pageNum - 1) * pageSize; + if (from >= total) + { + return new RegHistoryPageVO<>(new ArrayList(), total); + } + int to = Math.min(total, from + pageSize); + return new RegHistoryPageVO<>(new ArrayList<>(all.subList(from, to)), total); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/support/RegHistoryStatCalculator.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/support/RegHistoryStatCalculator.java new file mode 100644 index 000000000..9280f1e48 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/support/RegHistoryStatCalculator.java @@ -0,0 +1,1064 @@ +package com.ruoyi.train.support; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Period; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import com.ruoyi.train.domain.vo.RegHistoryDetailVO; +import com.ruoyi.train.domain.vo.RegHistoryFactVO; +import com.ruoyi.train.domain.vo.RegHistoryGroupVO; +import com.ruoyi.train.domain.vo.RegHistoryOverviewVO; +import com.ruoyi.train.domain.vo.RegHistoryTimeBucketVO; + +/** + * 历史报到统计计算内核(纯函数,无外部依赖,便于单元测试)。 + * + *

口径(固定)

+ *
    + *
  • 不使用 train_class_report;报到状态取 enrollment_status,报到时间取 join_time。
  • + *
  • 已报到 = enrollment_status in (1,4) 且 join_time 非空。
  • + *
  • 应报到 = enrollment_status in (0,1,2,4),放弃(3)整体排除。
  • + *
  • 耗时类指标(到训耗时、耗时分位、平均到训时刻)不在统计范围。
  • + *
+ * + *

本类与既有 {@code TrainRegDataServiceImpl} 的统计逻辑完全独立,仅被新增的 + * 历史报到统计服务调用。

+ * + * @author ruoyi + */ +public final class RegHistoryStatCalculator +{ + /** 待报到 */ + public static final int STATUS_WAITING = 0; + + /** 已报到 */ + public static final int STATUS_REPORTED = 1; + + /** 请假 */ + public static final int STATUS_LEAVE = 2; + + /** 放弃(不计入应报到) */ + public static final int STATUS_ABANDONED = 3; + + /** 已完成(视为已报到) */ + public static final int STATUS_COMPLETED = 4; + + /** 默认目标报到率(%) */ + public static final double DEFAULT_TARGET_RATE = 90D; + + public static final String GROUP_BATCH = "batch"; + public static final String GROUP_DEPT = "dept"; + public static final String GROUP_MAJOR = "major"; + public static final String GROUP_PLACE = "place"; + public static final String GROUP_GENDER = "gender"; + public static final String GROUP_AGE = "ageBand"; + public static final String GROUP_ACCOMMODATION = "accommodation"; + public static final String GROUP_FIRST_TIME = "firstTime"; + + public static final String GRANULARITY_DAY = "day"; + public static final String GRANULARITY_WEEK = "week"; + public static final String GRANULARITY_MONTH = "month"; + public static final String GRANULARITY_HOUR = "hour"; + + /** 集中报到判定:单小时最少人数 */ + private static final int CONCENTRATED_MIN_COUNT = 10; + + /** 集中报到判定:单小时占已报到最小比例 */ + private static final double CONCENTRATED_RATIO = 0.5D; + + /** 报到率波动阈值(百分点) */ + private static final double VOLATILITY_PP = 10D; + + /** 单位离群阈值(百分点) */ + private static final double OUTLIER_PP = 20D; + + /** 单位离群最小应报到样本 */ + private static final int OUTLIER_MIN_EXPECTED = 10; + + /** 波动对比回看期数 */ + private static final int VOLATILITY_LOOKBACK = 6; + + private static final Set GROUP_TYPES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + GROUP_BATCH, GROUP_DEPT, GROUP_MAJOR, GROUP_PLACE, GROUP_GENDER, + GROUP_AGE, GROUP_ACCOMMODATION, GROUP_FIRST_TIME))); + + private static final Set GRANULARITIES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + GRANULARITY_DAY, GRANULARITY_WEEK, GRANULARITY_MONTH, GRANULARITY_HOUR))); + + private static final DateTimeFormatter FMT_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter FMT_MONTH_DAY = DateTimeFormatter.ofPattern("MM-dd"); + private static final DateTimeFormatter FMT_MONTH = DateTimeFormatter.ofPattern("yyyy-MM"); + private static final DateTimeFormatter FMT_HOUR = DateTimeFormatter.ofPattern("HH"); + private static final DateTimeFormatter FMT_DATETIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private static final String UNKNOWN = "未知"; + private static final String EMPTY_PLACE = "未填写"; + private static final String NO_DEPT = "未分配单位"; + + private RegHistoryStatCalculator() + { + } + + // ------------------------------------------------------------------ + // 一、口径判定 + // ------------------------------------------------------------------ + + /** 是否计入应报到:status in (0,1,2,4) */ + public static boolean isExpected(Integer status) + { + return status != null && status != STATUS_ABANDONED; + } + + /** 状态本身是否代表"已报到过":status in (1,4) */ + public static boolean isReportedStatus(Integer status) + { + return status != null && (status == STATUS_REPORTED || status == STATUS_COMPLETED); + } + + /** 是否已报到:status in (1,4) 且 join_time 非空 */ + public static boolean isReported(Integer status, Date joinTime) + { + return isReportedStatus(status) && joinTime != null; + } + + public static boolean isLeave(Integer status) + { + return status != null && status == STATUS_LEAVE; + } + + public static boolean isWaiting(Integer status) + { + return status != null && status == STATUS_WAITING; + } + + /** + * 迟到判定基准:报到截止时间 → 报到开始时间+24h → 开班时间。 + * + * @param fact 事实行 + * @return 判定基准,全部缺失时返回 null + */ + public static Date lateThreshold(RegHistoryFactVO fact) + { + if (fact == null) + { + return null; + } + if (fact.getReportDeadline() != null) + { + return fact.getReportDeadline(); + } + if (fact.getReportStartTime() != null) + { + return new Date(fact.getReportStartTime().getTime() + 24L * 60L * 60L * 1000L); + } + return fact.getStartTime(); + } + + /** 是否迟到(仅对已报到记录有意义) */ + public static boolean isLate(RegHistoryFactVO fact) + { + Date threshold = lateThreshold(fact); + return fact != null && fact.getJoinTime() != null && threshold != null && fact.getJoinTime().after(threshold); + } + + /** 迟到分钟数,非迟到返回 0 */ + public static int lateMinutes(RegHistoryFactVO fact) + { + Date threshold = lateThreshold(fact); + if (fact == null || fact.getJoinTime() == null || threshold == null || !fact.getJoinTime().after(threshold)) + { + return 0; + } + return (int) ((fact.getJoinTime().getTime() - threshold.getTime()) / 60000L); + } + + /** 是否逾期未报到:status=0 且当前时间已超过报到判定基准 */ + public static boolean isOverdue(RegHistoryFactVO fact, Date now) + { + if (fact == null || !isWaiting(fact.getEnrollmentStatus())) + { + return false; + } + Date threshold = lateThreshold(fact); + return threshold != null && now != null && now.after(threshold); + } + + /** 年龄段:30岁及以下 / 31-40 / 41-50 / 51岁及以上 / 未知 */ + public static String ageBand(Date birth, Date now) + { + if (birth == null) + { + return UNKNOWN; + } + LocalDate birthDate = LocalDateTime.ofInstant(birth.toInstant(), ZoneId.systemDefault()).toLocalDate(); + LocalDate nowDate = now == null ? LocalDate.now() + : LocalDateTime.ofInstant(now.toInstant(), ZoneId.systemDefault()).toLocalDate(); + int age = Period.between(birthDate, nowDate).getYears(); + if (age <= 30) + { + return "30岁及以下"; + } + if (age <= 40) + { + return "31-40岁"; + } + if (age <= 50) + { + return "41-50岁"; + } + return "51岁及以上"; + } + + /** 参训状态文案 */ + public static String statusText(Integer status) + { + if (status == null) + { + return UNKNOWN; + } + switch (status) + { + case STATUS_WAITING: + return "待报到"; + case STATUS_REPORTED: + return "已报到"; + case STATUS_LEAVE: + return "请假"; + case STATUS_ABANDONED: + return "放弃"; + case STATUS_COMPLETED: + return "已完成"; + default: + return "状态" + status; + } + } + + /** + * 数据质量异常标签(不含"逾期未报到",该口径单独统计)。 + */ + public static List exceptions(RegHistoryFactVO fact, Date now) + { + List list = new ArrayList<>(); + if (fact == null) + { + return list; + } + Integer status = fact.getEnrollmentStatus(); + Date joinTime = fact.getJoinTime(); + if (isReportedStatus(status) && joinTime == null) + { + list.add("状态时间不一致"); + } + if (isWaiting(status) && joinTime != null) + { + list.add("状态滞后"); + } + if (joinTime != null && fact.getEndTime() != null && joinTime.after(fact.getEndTime())) + { + list.add("报到晚于结课"); + } + if (joinTime != null && fact.getPlanCreateTime() != null && joinTime.before(fact.getPlanCreateTime())) + { + list.add("报到早于计划创建"); + } + if (joinTime != null && now != null && joinTime.after(now)) + { + list.add("未来时间"); + } + if (isLeave(status) && joinTime != null) + { + list.add("请假有报到时间"); + } + return list; + } + + // ------------------------------------------------------------------ + // 二、数据集准备(过滤 + 去重) + // ------------------------------------------------------------------ + + /** + * 数据集:过滤放弃记录并完成同批次同人去重。 + */ + public static class Dataset + { + private final List rows; + + private final int duplicateRemovedCount; + + public Dataset(List rows, int duplicateRemovedCount) + { + this.rows = rows; + this.duplicateRemovedCount = duplicateRemovedCount; + } + + public List getRows() + { + return rows; + } + + public int getDuplicateRemovedCount() + { + return duplicateRemovedCount; + } + } + + /** + * 过滤 + 去重。 + * + *

去重键 = plan_id + student_id(student_id 为空时退化为 enrollment_id); + * 保留优先级:已报到(1/4 且 join_time 非空) > 已完成(4) > 已报到(1) > 其他, + * 同级取 enrollment_id 较大者。

+ */ + public static Dataset prepare(List raw) + { + List expected = new ArrayList<>(); + if (raw != null) + { + for (RegHistoryFactVO fact : raw) + { + if (fact != null && isExpected(fact.getEnrollmentStatus())) + { + expected.add(fact); + } + } + } + Map kept = new LinkedHashMap<>(); + int removed = 0; + for (RegHistoryFactVO fact : expected) + { + String key = dedupeKey(fact); + RegHistoryFactVO current = kept.get(key); + if (current == null) + { + kept.put(key, fact); + } + else + { + removed++; + if (comparePriority(fact, current) > 0) + { + kept.put(key, fact); + } + } + } + return new Dataset(new ArrayList<>(kept.values()), removed); + } + + private static String dedupeKey(RegHistoryFactVO fact) + { + if (fact.getStudentId() == null || fact.getStudentId().trim().isEmpty()) + { + return "E#" + (fact.getEnrollmentId() == null ? 0L : fact.getEnrollmentId()); + } + return fact.getPlanId() + "#" + fact.getStudentId(); + } + + private static int priority(RegHistoryFactVO fact) + { + if (isReported(fact.getEnrollmentStatus(), fact.getJoinTime())) + { + return 3; + } + Integer status = fact.getEnrollmentStatus(); + if (status != null && status == STATUS_COMPLETED) + { + return 2; + } + if (status != null && status == STATUS_REPORTED) + { + return 1; + } + return 0; + } + + private static int comparePriority(RegHistoryFactVO left, RegHistoryFactVO right) + { + int byPriority = Integer.compare(priority(left), priority(right)); + if (byPriority != 0) + { + return byPriority; + } + long leftId = left.getEnrollmentId() == null ? 0L : left.getEnrollmentId(); + long rightId = right.getEnrollmentId() == null ? 0L : right.getEnrollmentId(); + return Long.compare(leftId, rightId); + } + + // ------------------------------------------------------------------ + // 三、总览 + // ------------------------------------------------------------------ + + public static RegHistoryOverviewVO buildOverview(List rows, double targetRate) + { + RegHistoryOverviewVO vo = new RegHistoryOverviewVO(); + Date now = new Date(); + int expected = 0; + int reported = 0; + int attendanceDenominator = 0; + int waiting = 0; + int waitingOverdue = 0; + int leave = 0; + int inconsistent = 0; + int late = 0; + int abnormal = 0; + Set plans = new HashSet<>(); + Set persons = new HashSet<>(); + Map batchCounter = new HashMap<>(); + + if (rows != null) + { + for (RegHistoryFactVO fact : rows) + { + expected++; + plans.add(fact.getPlanId()); + Integer status = fact.getEnrollmentStatus(); + if (isLeave(status)) + { + leave++; + } + if (isWaiting(status)) + { + waiting++; + if (isOverdue(fact, now)) + { + waitingOverdue++; + } + } + if (status != null && (status == STATUS_WAITING || status == STATUS_REPORTED + || status == STATUS_COMPLETED)) + { + attendanceDenominator++; + } + boolean reportedFlag = isReported(status, fact.getJoinTime()); + if (reportedFlag) + { + reported++; + if (fact.getStudentId() != null) + { + persons.add(fact.getStudentId()); + } + if (isLate(fact)) + { + late++; + } + } + else if (isReportedStatus(status)) + { + inconsistent++; + } + if (!exceptions(fact, now).isEmpty()) + { + abnormal++; + } + int[] counter = batchCounter.get(fact.getPlanId()); + if (counter == null) + { + counter = new int[2]; + batchCounter.put(fact.getPlanId(), counter); + } + counter[0]++; + if (reportedFlag) + { + counter[1]++; + } + } + } + + int targetMet = 0; + for (int[] counter : batchCounter.values()) + { + if (rate(counter[1], counter[0]) >= targetRate) + { + targetMet++; + } + } + + vo.setBatchCount(plans.size()); + vo.setExpectedCount(expected); + vo.setReportedCount(reported); + vo.setReportedPersonCount(persons.size()); + vo.setReportRate(rate(reported, expected)); + vo.setAttendanceRate(rate(reported, attendanceDenominator)); + vo.setWaitingCount(waiting); + vo.setWaitingOverdueCount(waitingOverdue); + vo.setLeaveCount(leave); + vo.setInconsistentCount(inconsistent); + vo.setLateCount(late); + vo.setLateRate(rate(late, reported)); + vo.setOnTimeRate(reported > 0 ? round1((reported - late) * 100D / reported) : 0D); + vo.setAbnormalCount(abnormal); + vo.setTargetRate(targetRate); + vo.setTargetMetBatchCount(targetMet); + return vo; + } + + // ------------------------------------------------------------------ + // 四、分组汇总 + // ------------------------------------------------------------------ + + public static List buildGroups(List rows, String groupBy, double targetRate) + { + List groups = new ArrayList<>(); + if (rows == null || rows.isEmpty()) + { + return groups; + } + String dimension = normalizeGroupBy(groupBy); + Date now = new Date(); + Set firstEnrollments = GROUP_FIRST_TIME.equals(dimension) + ? firstEnrollmentIds(rows) : Collections.emptySet(); + + Map> buckets = new LinkedHashMap<>(); + Map labels = new HashMap<>(); + Map startTimes = new HashMap<>(); + Map planStatuses = new HashMap<>(); + for (RegHistoryFactVO fact : rows) + { + String key = groupKey(fact, dimension, firstEnrollments, now); + if (!buckets.containsKey(key)) + { + buckets.put(key, new ArrayList()); + labels.put(key, groupLabel(fact, dimension, key, now)); + startTimes.put(key, fact.getStartTime()); + planStatuses.put(key, fact.getPlanStatus()); + } + buckets.get(key).add(fact); + } + + int reportedTotal = 0; + for (RegHistoryFactVO fact : rows) + { + if (isReported(fact.getEnrollmentStatus(), fact.getJoinTime())) + { + reportedTotal++; + } + } + double globalRate = rate(reportedTotal, rows.size()); + + for (Map.Entry> entry : buckets.entrySet()) + { + RegHistoryGroupVO group = buildGroup(entry.getKey(), labels.get(entry.getKey()), + entry.getValue(), targetRate, globalRate); + group.setPlanStatus(planStatuses.get(entry.getKey())); + groups.add(group); + } + + if (GROUP_BATCH.equals(dimension)) + { + final Map order = startTimes; + groups.sort(Comparator.comparing( + (RegHistoryGroupVO group) -> order.get(group.getGroupKey()), + Comparator.nullsLast(Comparator.reverseOrder()))); + markVolatility(groups); + } + else + { + groups.sort(Comparator.comparing(RegHistoryGroupVO::getExpectedCount).reversed() + .thenComparing(RegHistoryGroupVO::getGroupLabel)); + } + return groups; + } + + private static RegHistoryGroupVO buildGroup(String key, String label, List rows, + double targetRate, double globalRate) + { + RegHistoryGroupVO vo = new RegHistoryGroupVO(); + vo.setGroupKey(key); + vo.setGroupLabel(label); + + int expected = 0; + int reported = 0; + int attendanceDenominator = 0; + int waiting = 0; + int leave = 0; + int inconsistent = 0; + int late = 0; + Set persons = new HashSet<>(); + Set plans = new HashSet<>(); + Date first = null; + Date last = null; + Map hourly = new HashMap<>(); + + for (RegHistoryFactVO fact : rows) + { + expected++; + plans.add(fact.getPlanId()); + Integer status = fact.getEnrollmentStatus(); + if (isLeave(status)) + { + leave++; + } + if (isWaiting(status)) + { + waiting++; + } + if (status != null && (status == STATUS_WAITING || status == STATUS_REPORTED + || status == STATUS_COMPLETED)) + { + attendanceDenominator++; + } + if (isReported(status, fact.getJoinTime())) + { + reported++; + if (fact.getStudentId() != null) + { + persons.add(fact.getStudentId()); + } + if (isLate(fact)) + { + late++; + } + Date joinTime = fact.getJoinTime(); + if (first == null || joinTime.before(first)) + { + first = joinTime; + } + if (last == null || joinTime.after(last)) + { + last = joinTime; + } + hourly.merge(hourKey(joinTime), 1, Integer::sum); + } + else if (isReportedStatus(status)) + { + inconsistent++; + } + } + + int maxHourly = 0; + for (Integer value : hourly.values()) + { + if (value != null && value > maxHourly) + { + maxHourly = value; + } + } + int concentratedThreshold = Math.max(CONCENTRATED_MIN_COUNT, + (int) Math.ceil(reported * CONCENTRATED_RATIO)); + + vo.setBatchCount(plans.size()); + vo.setExpectedCount(expected); + vo.setReportedCount(reported); + vo.setReportedPersonCount(persons.size()); + vo.setReportRate(rate(reported, expected)); + vo.setAttendanceRate(rate(reported, attendanceDenominator)); + vo.setWaitingCount(waiting); + vo.setLeaveCount(leave); + vo.setInconsistentCount(inconsistent); + vo.setLateCount(late); + vo.setLateRate(rate(late, reported)); + vo.setOnTimeRate(reported > 0 ? round1((reported - late) * 100D / reported) : 0D); + vo.setTargetMet(vo.getReportRate() >= targetRate); + vo.setFirstArriveTime(first == null ? null : FMT_DATETIME.format(toLocal(first))); + vo.setLastArriveTime(last == null ? null : FMT_DATETIME.format(toLocal(last))); + double gap = round1(vo.getReportRate() - globalRate); + vo.setGapToGlobal(gap); + vo.setOutlierFlag(Math.abs(gap) >= OUTLIER_PP && expected >= OUTLIER_MIN_EXPECTED); + vo.setConcentratedFlag(reported >= CONCENTRATED_MIN_COUNT && maxHourly >= concentratedThreshold); + return vo; + } + + /** + * 批次波动标记:与最近 6 期(不含自身)均值偏差 ≥ 10 个百分点,样本不足 2 期不标记。 + * 入参需已按开班时间倒序排列。 + */ + private static void markVolatility(List groups) + { + for (int i = 1; i < groups.size(); i++) + { + int from = Math.max(0, i - VOLATILITY_LOOKBACK); + double sum = 0D; + int count = 0; + for (int j = from; j < i; j++) + { + Double rate = groups.get(j).getReportRate(); + if (rate != null) + { + sum += rate; + count++; + } + } + if (count < 2) + { + continue; + } + double average = sum / count; + Double current = groups.get(i).getReportRate(); + if (current != null && Math.abs(current - average) >= VOLATILITY_PP) + { + groups.get(i).setVolatilityFlag(Boolean.TRUE); + } + } + } + + private static String groupKey(RegHistoryFactVO fact, String dimension, Set firstEnrollments, Date now) + { + switch (dimension) + { + case GROUP_DEPT: + // 送培单位以学员主档 train_student.dept_name 为准,不再依赖 sys_dept 录入; + // 名称为空时统一归入"未分配单位",避免不同单位被同一空 ID 合并。 + return textOr(fact.getDeptName(), NO_DEPT); + case GROUP_MAJOR: + return fact.getClassMajor() == null ? "-1" : String.valueOf(fact.getClassMajor()); + case GROUP_PLACE: + return textOr(fact.getTrainAddress(), "__empty__"); + case GROUP_GENDER: + return normalizeGenderKey(fact.getGender()); + case GROUP_AGE: + return ageBand(fact.getBirth(), now); + case GROUP_ACCOMMODATION: + return fact.getAccommodationRequired() == null ? "-1" + : String.valueOf(fact.getAccommodationRequired()); + case GROUP_FIRST_TIME: + return firstEnrollments.contains(enrollmentKey(fact)) ? "first" : "repeat"; + default: + return textOr(fact.getPlanId(), "-"); + } + } + + private static String groupLabel(RegHistoryFactVO fact, String dimension, String key, Date now) + { + switch (dimension) + { + case GROUP_DEPT: + return textOr(fact.getDeptName(), NO_DEPT); + case GROUP_MAJOR: + return fact.getClassMajor() == null ? "未设置专业" : "专业" + fact.getClassMajor(); + case GROUP_PLACE: + return textOr(fact.getTrainAddress(), EMPTY_PLACE); + case GROUP_GENDER: + return genderLabel(key); + case GROUP_AGE: + return key; + case GROUP_ACCOMMODATION: + Integer required = fact.getAccommodationRequired(); + if (required == null) + { + return EMPTY_PLACE; + } + return required == 1 ? "需住宿" : "不住宿"; + case GROUP_FIRST_TIME: + return "first".equals(key) ? "首次参训" : "复训"; + default: + return textOr(fact.getClassName(), textOr(fact.getPlanName(), textOr(fact.getPlanId(), "-"))); + } + } + + private static Set firstEnrollmentIds(List rows) + { + Map earliest = new HashMap<>(); + for (RegHistoryFactVO fact : rows) + { + if (fact.getStudentId() == null) + { + continue; + } + RegHistoryFactVO current = earliest.get(fact.getStudentId()); + if (current == null || earlier(fact, current)) + { + earliest.put(fact.getStudentId(), fact); + } + } + Set ids = new HashSet<>(); + for (RegHistoryFactVO fact : earliest.values()) + { + ids.add(enrollmentKey(fact)); + } + return ids; + } + + private static boolean earlier(RegHistoryFactVO left, RegHistoryFactVO right) + { + Date leftTime = left.getEnrollCreateTime() != null ? left.getEnrollCreateTime() : left.getStartTime(); + Date rightTime = right.getEnrollCreateTime() != null ? right.getEnrollCreateTime() : right.getStartTime(); + if (leftTime == null) + { + return false; + } + if (rightTime == null) + { + return true; + } + if (leftTime.equals(rightTime)) + { + long leftId = left.getEnrollmentId() == null ? 0L : left.getEnrollmentId(); + long rightId = right.getEnrollmentId() == null ? 0L : right.getEnrollmentId(); + return leftId < rightId; + } + return leftTime.before(rightTime); + } + + private static String enrollmentKey(RegHistoryFactVO fact) + { + return "E#" + (fact.getEnrollmentId() == null ? 0L : fact.getEnrollmentId()); + } + + // ------------------------------------------------------------------ + // 五、时间分布 + // ------------------------------------------------------------------ + + public static List buildTimeBuckets(List rows, String granularity) + { + String dimension = normalizeGranularity(granularity); + Map counter = new TreeMap<>(); + Map labels = new HashMap<>(); + if (rows != null) + { + for (RegHistoryFactVO fact : rows) + { + if (!isReported(fact.getEnrollmentStatus(), fact.getJoinTime())) + { + continue; + } + LocalDateTime time = toLocal(fact.getJoinTime()); + String key = bucketKey(time, dimension); + counter.merge(key, 1, Integer::sum); + labels.put(key, bucketLabel(time, dimension)); + } + } + int total = 0; + int max = 0; + for (Integer value : counter.values()) + { + total += value; + if (value > max) + { + max = value; + } + } + List buckets = new ArrayList<>(); + int cumulative = 0; + for (Map.Entry entry : counter.entrySet()) + { + cumulative += entry.getValue(); + RegHistoryTimeBucketVO vo = new RegHistoryTimeBucketVO(); + vo.setGranularity(dimension); + vo.setPeriodKey(entry.getKey()); + vo.setPeriodLabel(labels.get(entry.getKey())); + vo.setReportedCount(entry.getValue()); + vo.setShare(rate(entry.getValue(), total)); + vo.setCumulativeShare(rate(cumulative, total)); + vo.setPeak(max > 0 && entry.getValue() == max); + buckets.add(vo); + } + return buckets; + } + + private static String bucketKey(LocalDateTime time, String granularity) + { + switch (granularity) + { + case GRANULARITY_WEEK: + WeekFields weekFields = WeekFields.ISO; + int week = time.get(weekFields.weekOfWeekBasedYear()); + int weekYear = time.get(weekFields.weekBasedYear()); + return weekYear + "-W" + String.format("%02d", week); + case GRANULARITY_MONTH: + return FMT_MONTH.format(time); + case GRANULARITY_HOUR: + return FMT_HOUR.format(time); + default: + return FMT_DATE.format(time); + } + } + + private static String bucketLabel(LocalDateTime time, String granularity) + { + switch (granularity) + { + case GRANULARITY_WEEK: + WeekFields weekFields = WeekFields.ISO; + return time.get(weekFields.weekBasedYear()) + "年第" + + time.get(weekFields.weekOfWeekBasedYear()) + "周"; + case GRANULARITY_MONTH: + return FMT_MONTH.format(time); + case GRANULARITY_HOUR: + return FMT_HOUR.format(time) + ":00"; + default: + return FMT_MONTH_DAY.format(time); + } + } + + // ------------------------------------------------------------------ + // 六、明细 + // ------------------------------------------------------------------ + + public static List buildDetails(List rows) + { + List details = new ArrayList<>(); + if (rows == null) + { + return details; + } + Date now = new Date(); + for (RegHistoryFactVO fact : rows) + { + RegHistoryDetailVO vo = new RegHistoryDetailVO(); + vo.setEnrollmentId(fact.getEnrollmentId()); + vo.setPlanId(fact.getPlanId()); + vo.setClassName(fact.getClassName()); + vo.setPlanName(fact.getPlanName()); + vo.setStartTime(fact.getStartTime()); + vo.setReportStartTime(fact.getReportStartTime()); + vo.setReportDeadline(fact.getReportDeadline()); + vo.setStudentId(fact.getStudentId()); + vo.setStudentName(fact.getStudentName()); + vo.setPersonNo(fact.getPersonNo()); + vo.setGender(genderLabel(normalizeGenderKey(fact.getGender()))); + vo.setAgeBand(ageBand(fact.getBirth(), now)); + vo.setDeptId(fact.getDeptId()); + vo.setDeptName(textOr(fact.getDeptName(), NO_DEPT)); + vo.setEnrollmentStatus(fact.getEnrollmentStatus()); + vo.setStatusText(statusText(fact.getEnrollmentStatus())); + vo.setJoinTime(fact.getJoinTime()); + + Date joinTime = fact.getJoinTime(); + boolean reported = isReported(fact.getEnrollmentStatus(), joinTime); + boolean late = reported && isLate(fact); + vo.setReportedFlag(reported); + vo.setLateFlag(late); + vo.setLateMinutes(late ? lateMinutes(fact) : 0); + vo.setLateText(!reported ? "未报到" : (late ? "迟到 " + lateMinutes(fact) + " 分钟" : "准点")); + if (joinTime != null) + { + LocalDateTime time = toLocal(joinTime); + vo.setReportDate(FMT_DATE.format(time)); + vo.setReportHour(FMT_HOUR.format(time) + ":00"); + } + vo.setInconsistentFlag(isReportedStatus(fact.getEnrollmentStatus()) && joinTime == null); + vo.setLaggingFlag(isWaiting(fact.getEnrollmentStatus()) && joinTime != null); + vo.setOverdueFlag(isOverdue(fact, now)); + + List exceptions = exceptions(fact, now); + vo.setAbnormalFlag(!exceptions.isEmpty()); + vo.setTimeConflictFlag(exceptions.contains("报到晚于结课") + || exceptions.contains("报到早于计划创建") + || exceptions.contains("未来时间")); + vo.setExceptionText(exceptions.isEmpty() ? "" : String.join("、", exceptions)); + details.add(vo); + } + return details; + } + + // ------------------------------------------------------------------ + // 七、工具方法 + // ------------------------------------------------------------------ + + public static String normalizeGroupBy(String groupBy) + { + if (groupBy == null) + { + return GROUP_BATCH; + } + String value = groupBy.trim(); + return GROUP_TYPES.contains(value) ? value : GROUP_BATCH; + } + + public static String normalizeGranularity(String granularity) + { + if (granularity == null) + { + return GRANULARITY_DAY; + } + String value = granularity.trim(); + return GRANULARITIES.contains(value) ? value : GRANULARITY_DAY; + } + + /** 百分比,保留 1 位小数,分母为 0 返回 0 */ + public static double rate(int part, int total) + { + if (total <= 0) + { + return 0D; + } + return round1(part * 100D / total); + } + + public static double round1(double value) + { + return Math.round(value * 10D) / 10D; + } + + private static String hourKey(Date date) + { + return FMT_HOUR.format(toLocal(date)); + } + + private static LocalDateTime toLocal(Date date) + { + return LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()); + } + + /** + * 性别编码归一化(兼容数字编码与中文/英文文本)。 + * + *

编码沿用系统字典 {@code sys_user_sex} / {@code sys_user_sex2} 的约定: + * 0 = 男,1 = 女,2 = 未知。历史数据中同时存在 {@code "0"/"1"}、 + * {@code "男"/"女"}、{@code "M"/"F"} 等写法,统一收敛到 0/1/2, + * 避免同一性别因写法不同被拆成多个统计项。

+ * + * @param raw 学员主档 s_gender 原始值 + * @return 归一化编码:0/1/2,或无法识别的原始值 + */ + private static String normalizeGenderKey(String raw) + { + String value = raw == null ? "" : raw.trim(); + if (value.isEmpty()) + { + return "2"; + } + if ("0".equals(value) || "男".equals(value) || "M".equalsIgnoreCase(value) || "male".equalsIgnoreCase(value)) + { + return "0"; + } + if ("1".equals(value) || "女".equals(value) || "F".equalsIgnoreCase(value) || "female".equalsIgnoreCase(value)) + { + return "1"; + } + if ("2".equals(value) || "未知".equals(value) || "U".equalsIgnoreCase(value) + || "unknown".equalsIgnoreCase(value)) + { + return "2"; + } + return value; + } + + /** + * 性别编码转展示文本:0 = 男,1 = 女,2 = 未知;其他编码原样返回(保持编码与含义对应)。 + */ + private static String genderLabel(String key) + { + if ("0".equals(key)) + { + return "男"; + } + if ("1".equals(key)) + { + return "女"; + } + if ("2".equals(key)) + { + return UNKNOWN; + } + return key == null ? UNKNOWN : key; + } + + private static String textOr(String value, String fallback) + { + return value == null || value.trim().isEmpty() ? fallback : value.trim(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainRegHistoryStatMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainRegHistoryStatMapper.xml new file mode 100644 index 000000000..6d6daa748 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainRegHistoryStatMapper.xml @@ -0,0 +1,80 @@ + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainRegHistoryStatMapperContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainRegHistoryStatMapperContractTest.java new file mode 100644 index 000000000..3a12b4acb --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainRegHistoryStatMapperContractTest.java @@ -0,0 +1,88 @@ +package com.ruoyi.train.mapper; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.session.Configuration; +import org.junit.Test; + +/** + * 历史报到统计 Mapper 契约测试。 + * + *

保护三条硬约束:

+ *
    + *
  1. 只读取 train_enrollment / train_class_plan / train_student / sys_dept;
  2. + *
  3. 严禁引用 train_class_report;
  4. + *
  5. 报到时间只使用 join_time,且放弃(3)在 SQL 层被排除。
  6. + *
+ * + * @author ruoyi + */ +public class TrainRegHistoryStatMapperContractTest +{ + private static final String MAPPER_XML = "/mapper/train/TrainRegHistoryStatMapper.xml"; + + private static final String STATEMENT_ID = + "com.ruoyi.train.mapper.TrainRegHistoryStatMapper.selectHistoryFacts"; + + @Test + public void mapperXmlParsesValidMyBatisMappedStatements() throws Exception + { + Configuration configuration = new Configuration(); + try (InputStream inputStream = getClass().getResourceAsStream(MAPPER_XML)) + { + assertNotNull("TrainRegHistoryStatMapper.xml must exist in classpath", inputStream); + XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, + MAPPER_XML, configuration.getSqlFragments()); + mapperParser.parse(); + } + assertTrue("Statement must be registered", configuration.hasStatement(STATEMENT_ID)); + } + + @Test + public void mapperOnlyReadsEnrollmentScopeAndNeverUsesClassReport() throws Exception + { + String xml = loadMapperXml(); + assertTrue(xml.contains("train_enrollment")); + assertTrue(xml.contains("train_class_plan")); + assertTrue(xml.contains("train_student")); + // 送培单位改由学员主档单位名称决定,不再关联 sys_dept + assertTrue("送培单位必须取 train_student.dept_name", xml.contains("s.dept_name")); + assertTrue(xml.contains("as deptname")); + assertFalse("送培单位统计不得再依赖 sys_dept 录入", xml.contains("sys_dept")); + assertFalse("历史报到统计不得引用 train_class_report", xml.contains("train_class_report")); + assertTrue("报到时间必须取 join_time", xml.contains("e.join_time")); + assertTrue("放弃(3)必须在 SQL 层排除", xml.contains("e.enrollment_status <> 3")); + assertTrue(xml.contains("p.del_flag = '0'")); + assertTrue("必须限制单次取数上限", xml.contains("limit #{q.limit}")); + // 未开始批次必须按生效状态判定:持久化 0 且已过开班时间视为进行中, + // 与列表接口 effectivePlanStatusPolicy 一致,避免整批漏统计。 + assertTrue("未开始批次必须按生效状态判定", + xml.contains("case when p.plan_status = 0 and p.start_time <= current_timestamp then 1")); + assertFalse("不得再直接排除持久化状态 0", + xml.contains("and p.plan_status <> 0")); + } + + private String loadMapperXml() throws Exception + { + try (InputStream input = getClass().getResourceAsStream(MAPPER_XML)) + { + assertNotNull("missing mapper " + MAPPER_XML, input); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + while ((length = input.read(buffer)) != -1) + { + output.write(buffer, 0, length); + } + String raw = new String(output.toByteArray(), StandardCharsets.UTF_8); + // 剔除 XML 注释后再做约束断言,注释中的口径说明不属于 SQL 语句 + return raw.replaceAll("(?s)", "").toLowerCase(); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/support/RegHistoryStatCalculatorTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/support/RegHistoryStatCalculatorTest.java new file mode 100644 index 000000000..7fc506675 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/support/RegHistoryStatCalculatorTest.java @@ -0,0 +1,324 @@ +package com.ruoyi.train.support; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import org.junit.Test; +import com.ruoyi.train.domain.vo.RegHistoryDetailVO; +import com.ruoyi.train.domain.vo.RegHistoryFactVO; +import com.ruoyi.train.domain.vo.RegHistoryGroupVO; +import com.ruoyi.train.domain.vo.RegHistoryOverviewVO; +import com.ruoyi.train.domain.vo.RegHistoryTimeBucketVO; + +/** + * 历史报到统计口径单元测试(纯计算,不依赖数据库)。 + * + *

覆盖:已报到判定、放弃过滤、去重优先级、报到率分母、请假口径、 + * 迟到基准回退、分组计数、时间分桶、明细异常标记、参数归一化。

+ * + * @author ruoyi + */ +public class RegHistoryStatCalculatorTest +{ + @Test + public void reportedStatusRequiresStatus1Or4AndJoinTime() + { + assertTrue(RegHistoryStatCalculator.isReported(1, date("2026-03-01 09:00"))); + assertTrue(RegHistoryStatCalculator.isReported(4, date("2026-03-01 09:00"))); + assertFalse("状态为已报到但报到时间为空,不得计为已报到", + RegHistoryStatCalculator.isReported(1, null)); + assertFalse(RegHistoryStatCalculator.isReported(0, date("2026-03-01 09:00"))); + assertFalse(RegHistoryStatCalculator.isReported(2, date("2026-03-01 09:00"))); + assertFalse(RegHistoryStatCalculator.isExpected(3)); + assertTrue(RegHistoryStatCalculator.isExpected(0)); + assertTrue(RegHistoryStatCalculator.isExpected(1)); + assertTrue(RegHistoryStatCalculator.isExpected(2)); + assertTrue(RegHistoryStatCalculator.isExpected(4)); + } + + @Test + public void prepareFiltersAbandonedAndDeduplicatesByPlanAndStudent() + { + List raw = new ArrayList<>(); + raw.add(fact(1L, "P1", "S1", 0, null)); + raw.add(fact(2L, "P1", "S1", 1, "2026-03-01 09:00")); + raw.add(fact(3L, "P1", "S2", 3, "2026-03-01 10:00")); + raw.add(fact(4L, "P1", "S3", 4, "2026-03-01 11:00")); + + RegHistoryStatCalculator.Dataset dataset = RegHistoryStatCalculator.prepare(raw); + + assertEquals("放弃记录被过滤,重复档案合并为 1 条", 2, dataset.getRows().size()); + assertEquals(1, dataset.getDuplicateRemovedCount()); + + RegHistoryFactVO kept = null; + for (RegHistoryFactVO row : dataset.getRows()) + { + if ("S1".equals(row.getStudentId())) + { + kept = row; + } + } + assertNotNull(kept); + assertEquals("应保留已报到的那条档案", Long.valueOf(2L), kept.getEnrollmentId()); + } + + @Test + public void overviewUsesExpectedAndReportedScope() + { + List rows = Arrays.asList( + fact(1L, "P1", "S1", 1, "2026-03-01 09:00"), + fact(2L, "P1", "S2", 4, "2026-03-01 09:10"), + fact(3L, "P1", "S3", 2, null), + fact(4L, "P1", "S4", 0, null), + fact(5L, "P1", "S5", 1, null)); + + RegHistoryOverviewVO overview = RegHistoryStatCalculator.buildOverview(rows, + RegHistoryStatCalculator.DEFAULT_TARGET_RATE); + + assertEquals(Integer.valueOf(5), overview.getExpectedCount()); + assertEquals(Integer.valueOf(2), overview.getReportedCount()); + assertEquals("去重人数只统计已报到人员", Integer.valueOf(2), overview.getReportedPersonCount()); + assertEquals("2/5 = 40.0%", Double.valueOf(40.0D), overview.getReportRate()); + assertEquals("请假不计入出勤分母:2/4 = 50.0%", Double.valueOf(50.0D), overview.getAttendanceRate()); + assertEquals(Integer.valueOf(1), overview.getLeaveCount()); + assertEquals(Integer.valueOf(1), overview.getWaitingCount()); + assertEquals("状态为已报到但无报到时间", Integer.valueOf(1), overview.getInconsistentCount()); + assertEquals(Integer.valueOf(1), overview.getBatchCount()); + assertEquals("单批次报到率 40% 未达 90% 目标", Integer.valueOf(0), overview.getTargetMetBatchCount()); + } + + @Test + public void lateThresholdFallsBackFromDeadlineToReportStartToPlanStart() + { + RegHistoryFactVO byDeadline = fact(1L, "P1", "S1", 1, "2026-03-01 09:30"); + byDeadline.setReportDeadline(date("2026-03-01 09:00")); + assertTrue(RegHistoryStatCalculator.isLate(byDeadline)); + assertEquals(30, RegHistoryStatCalculator.lateMinutes(byDeadline)); + + RegHistoryFactVO byReportStart = fact(2L, "P1", "S2", 1, "2026-03-01 18:00"); + byReportStart.setReportStartTime(date("2026-03-01 09:00")); + assertFalse("未配置截止时间时以报到开始时间 +24h 为基准,当日 18:00 不算迟到", + RegHistoryStatCalculator.isLate(byReportStart)); + + RegHistoryFactVO byPlanStart = fact(3L, "P1", "S3", 1, "2026-03-02 10:00"); + byPlanStart.setStartTime(date("2026-03-01 09:00")); + assertTrue(RegHistoryStatCalculator.isLate(byPlanStart)); + assertEquals(1500, RegHistoryStatCalculator.lateMinutes(byPlanStart)); + + RegHistoryFactVO noThreshold = fact(4L, "P1", "S4", 1, "2026-03-01 10:00"); + noThreshold.setStartTime(null); + noThreshold.setReportStartTime(null); + noThreshold.setReportDeadline(null); + assertFalse("无任何基准时不判定迟到", RegHistoryStatCalculator.isLate(noThreshold)); + } + + @Test + public void groupByDeptAggregatesCountsAndRates() + { + RegHistoryFactVO first = fact(1L, "P1", "S1", 1, "2026-03-01 09:00"); + first.setDeptId(10L); + first.setDeptName("运输部"); + RegHistoryFactVO second = fact(2L, "P2", "S2", 0, null); + second.setDeptId(10L); + second.setDeptName("运输部"); + RegHistoryFactVO third = fact(3L, "P1", "S3", 4, "2026-03-01 09:20"); + third.setDeptId(20L); + third.setDeptName("机务部"); + + List groups = RegHistoryStatCalculator.buildGroups( + Arrays.asList(first, second, third), "dept", RegHistoryStatCalculator.DEFAULT_TARGET_RATE); + + assertEquals(2, groups.size()); + RegHistoryGroupVO transport = findByLabel(groups, "运输部"); + assertNotNull(transport); + assertEquals(Integer.valueOf(2), transport.getExpectedCount()); + assertEquals(Integer.valueOf(1), transport.getReportedCount()); + assertEquals(Double.valueOf(50.0D), transport.getReportRate()); + assertEquals(Integer.valueOf(2), transport.getBatchCount()); + } + + @Test + public void groupByDeptKeepsDifferentUnitsApartWhenDeptIdMissing() + { + RegHistoryFactVO party = fact(1L, "P1", "S1", 1, "2026-03-01 09:00"); + party.setDeptName("党群工作组"); + RegHistoryFactVO maintenance = fact(2L, "P1", "S2", 1, "2026-03-01 09:10"); + maintenance.setDeptName("工务段"); + RegHistoryFactVO unknown = fact(3L, "P2", "S3", 0, null); + unknown.setDeptId(10L); + unknown.setDeptName(null); + + List groups = RegHistoryStatCalculator.buildGroups( + Arrays.asList(party, maintenance, unknown), "dept", RegHistoryStatCalculator.DEFAULT_TARGET_RATE); + + assertEquals("dept_id 为空的不同单位必须按 dept_name 分开统计", 3, groups.size()); + assertNotNull(findByLabel(groups, "党群工作组")); + assertNotNull(findByLabel(groups, "工务段")); + assertNotNull(findByLabel(groups, "未分配单位")); + assertEquals(Double.valueOf(100.0D), findByLabel(groups, "党群工作组").getReportRate()); + } + + @Test + public void groupByGenderNormalizesCodesAndTextToSameBucket() + { + RegHistoryFactVO maleByCode = fact(1L, "P1", "S1", 1, "2026-03-01 09:00"); + maleByCode.setGender("0"); + RegHistoryFactVO maleByText = fact(2L, "P1", "S2", 1, "2026-03-01 09:10"); + maleByText.setGender("男"); + RegHistoryFactVO femaleByCode = fact(3L, "P1", "S3", 1, "2026-03-01 09:20"); + femaleByCode.setGender("1"); + RegHistoryFactVO femaleByText = fact(4L, "P1", "S4", 0, null); + femaleByText.setGender("女"); + RegHistoryFactVO unknown = fact(5L, "P1", "S5", 0, null); + unknown.setGender(null); + + List groups = RegHistoryStatCalculator.buildGroups( + Arrays.asList(maleByCode, maleByText, femaleByCode, femaleByText, unknown), + "gender", RegHistoryStatCalculator.DEFAULT_TARGET_RATE); + + assertEquals("性别应归一为 男/女/未知 三项", 3, groups.size()); + RegHistoryGroupVO male = findByLabel(groups, "男"); + assertNotNull("0 与 男 必须合并为同一组", male); + assertEquals(Integer.valueOf(2), male.getExpectedCount()); + assertEquals("0", male.getGroupKey()); + RegHistoryGroupVO female = findByLabel(groups, "女"); + assertNotNull("1 与 女 必须合并为同一组", female); + assertEquals(Integer.valueOf(2), female.getExpectedCount()); + assertEquals("1", female.getGroupKey()); + RegHistoryGroupVO unknownGroup = findByLabel(groups, "未知"); + assertNotNull(unknownGroup); + assertEquals("2", unknownGroup.getGroupKey()); + } + + @Test + public void groupByAgeBandAndFirstTime() + { + RegHistoryFactVO older = fact(1L, "P1", "S1", 1, "2026-03-01 09:00"); + older.setBirth(date("1980-05-01 00:00")); + older.setEnrollCreateTime(date("2025-01-01 08:00")); + + RegHistoryFactVO younger = fact(2L, "P2", "S2", 1, "2026-03-02 09:00"); + younger.setBirth(date("2000-05-01 00:00")); + younger.setEnrollCreateTime(date("2026-01-01 08:00")); + + List ageGroups = RegHistoryStatCalculator.buildGroups( + Arrays.asList(older, younger), "ageBand", RegHistoryStatCalculator.DEFAULT_TARGET_RATE); + assertEquals(2, ageGroups.size()); + assertNotNull(findByLabel(ageGroups, "41-50岁")); + assertNotNull(findByLabel(ageGroups, "30岁及以下")); + + List firstTimeGroups = RegHistoryStatCalculator.buildGroups( + Arrays.asList(older, younger), "firstTime", RegHistoryStatCalculator.DEFAULT_TARGET_RATE); + assertEquals("两名学员各自的最早档案均属首次参训,合并为 1 组", 1, firstTimeGroups.size()); + assertEquals(Integer.valueOf(2), firstTimeGroups.get(0).getExpectedCount()); + assertNotNull(findByLabel(firstTimeGroups, "首次参训")); + } + + @Test + public void timeBucketsOnlyCountReportedRows() + { + List rows = Arrays.asList( + fact(1L, "P1", "S1", 1, "2026-03-01 09:10"), + fact(2L, "P1", "S2", 1, "2026-03-01 09:40"), + fact(3L, "P1", "S3", 4, "2026-03-02 10:00"), + fact(4L, "P1", "S4", 0, null)); + + List hourBuckets = RegHistoryStatCalculator.buildTimeBuckets(rows, "hour"); + assertEquals("09 时 2 人、10 时 1 人,共 2 个小时桶", 2, hourBuckets.size()); + RegHistoryTimeBucketVO nineOClock = hourBuckets.get(0); + assertEquals("09:00", nineOClock.getPeriodLabel()); + assertEquals(Integer.valueOf(2), nineOClock.getReportedCount()); + assertEquals(Double.valueOf(66.7D), nineOClock.getShare()); + assertTrue(nineOClock.getPeak()); + + List dayBuckets = RegHistoryStatCalculator.buildTimeBuckets(rows, "day"); + assertEquals(2, dayBuckets.size()); + assertEquals(Integer.valueOf(2), dayBuckets.get(0).getReportedCount()); + assertEquals(Double.valueOf(66.7D), dayBuckets.get(0).getCumulativeShare()); + } + + @Test + public void detailsFlagLateInconsistentAndOverdue() + { + RegHistoryFactVO late = fact(1L, "P1", "S1", 1, "2026-03-01 10:00"); + late.setReportDeadline(date("2026-03-01 09:00")); + late.setEndTime(date("2026-03-10 18:00")); + + RegHistoryFactVO inconsistent = fact(2L, "P1", "S2", 4, null); + RegHistoryFactVO overdue = fact(3L, "P1", "S3", 0, null); + overdue.setReportDeadline(date("2026-03-05 09:00")); + + List details = RegHistoryStatCalculator.buildDetails( + Arrays.asList(late, inconsistent, overdue)); + + assertEquals(3, details.size()); + RegHistoryDetailVO lateDetail = details.get(0); + assertTrue(lateDetail.getLateFlag()); + assertEquals(Integer.valueOf(60), lateDetail.getLateMinutes()); + assertEquals("迟到 60 分钟", lateDetail.getLateText()); + assertEquals("2026-03-01", lateDetail.getReportDate()); + + assertTrue(details.get(1).getInconsistentFlag()); + assertEquals("状态时间不一致", details.get(1).getExceptionText()); + assertTrue(details.get(2).getOverdueFlag()); + assertFalse(details.get(2).getReportedFlag()); + assertEquals("未报到", details.get(2).getLateText()); + } + + @Test + public void normalizationFallsBackToSafeDefaults() + { + assertEquals("batch", RegHistoryStatCalculator.normalizeGroupBy(null)); + assertEquals("batch", RegHistoryStatCalculator.normalizeGroupBy("unknown")); + assertEquals("dept", RegHistoryStatCalculator.normalizeGroupBy("dept")); + // 已移除的维度(职级、名单来源)不再作为合法分组,统一回退到批次 + assertEquals("batch", RegHistoryStatCalculator.normalizeGroupBy("dutyLevel")); + assertEquals("batch", RegHistoryStatCalculator.normalizeGroupBy("enrollSource")); + assertEquals("day", RegHistoryStatCalculator.normalizeGranularity("")); + assertEquals("month", RegHistoryStatCalculator.normalizeGranularity("month")); + assertEquals(0D, RegHistoryStatCalculator.rate(3, 0), 0.0001D); + assertEquals(33.3D, RegHistoryStatCalculator.rate(1, 3), 0.0001D); + } + + private RegHistoryGroupVO findByLabel(List groups, String label) + { + for (RegHistoryGroupVO group : groups) + { + if (label.equals(group.getGroupLabel())) + { + return group; + } + } + return null; + } + + private RegHistoryFactVO fact(Long enrollmentId, String planId, String studentId, int status, String joinTime) + { + RegHistoryFactVO fact = new RegHistoryFactVO(); + fact.setEnrollmentId(enrollmentId); + fact.setPlanId(planId); + fact.setStudentId(studentId); + fact.setEnrollmentStatus(status); + fact.setJoinTime(date(joinTime)); + fact.setStartTime(date("2026-03-01 09:00")); + return fact; + } + + private Date date(String text) + { + if (text == null) + { + return null; + } + LocalDateTime local = LocalDateTime.parse(text.replace(' ', 'T')); + return Date.from(local.atZone(ZoneId.systemDefault()).toInstant()); + } +} diff --git a/RuoYi-Vue/sql/train/v2/030_train_class_major_dict_supplement.sql b/RuoYi-Vue/sql/train/v2/030_train_class_major_dict_supplement.sql new file mode 100644 index 000000000..c6e771fb3 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/030_train_class_major_dict_supplement.sql @@ -0,0 +1,66 @@ +-- ============================================================================== +-- RailwaySystem 数据库增量脚本: 030_train_class_major_dict_supplement.sql +-- 模块: 历史报到统计 —— 培训专业字典补充 +-- 目的: +-- 1) 补齐 train_class_major(培训专业)字典类型与备选专业项,保证统计展示与字典配置一致; +-- 2) 脚本幂等,可重复执行,不修改既有数据语义(仅在缺失时插入); +-- 3) 统计口径: 专业分组取 train_class_plan.class_major,展示标签取本字典 dict_label。 +-- 数据库: KingbaseES / PostgreSQL 语法(与 007_dicts.sql 保持一致) +-- 执行: ksql -h -p -U -d -f 030_train_class_major_dict_supplement.sql +-- ============================================================================== + +SET NAMES utf8; + +-- 1. 字典类型(缺失时补建) +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '培训专业', 'train_class_major', '0', 'admin', CURRENT_TIMESTAMP, + '培训班所属专业分类 (对应 train_class_plan.class_major)' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_class_major'); + +-- 2. 备选专业项(1-5),缺失时补齐 +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 1, '接触网工', '1', 'train_class_major', 'primary', 'Y', '0', 'admin', CURRENT_TIMESTAMP, '接触网检修专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '1'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 2, '电力线路工', '2', 'train_class_major', 'primary', 'N', '0', 'admin', CURRENT_TIMESTAMP, '电力线路检修专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '2'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 3, '信号工', '3', 'train_class_major', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, '信号设备维护专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '3'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 4, '通信工', '4', 'train_class_major', 'warning', 'N', '0', 'admin', CURRENT_TIMESTAMP, '通信设备维护专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '4'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +SELECT 5, '机车司机', '5', 'train_class_major', 'info', 'N', '0', 'admin', CURRENT_TIMESTAMP, '机车驾驶专业' +WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '5'); + +COMMIT; + +-- ============================================================================== +-- 附: 数据一致性核对(只读,按需执行) +-- ============================================================================== +-- 3.1 找出班级数据中"字典未配置"的专业值(结果非空时按下面模板补录) +-- SELECT p.class_major, COUNT(DISTINCT p.plan_id) AS plan_cnt +-- FROM train_class_plan p +-- WHERE p.del_flag = '0' +-- AND p.class_major IS NOT NULL +-- AND NOT EXISTS (SELECT 1 FROM sys_dict_data d +-- WHERE d.dict_type = 'train_class_major' +-- AND d.dict_value = CAST(p.class_major AS VARCHAR)) +-- GROUP BY p.class_major +-- ORDER BY p.class_major; +-- +-- 3.2 补录模板(示例: 6 = 桥隧工,按实际业务替换 label/sort/remark) +-- INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark) +-- SELECT 6, '桥隧工', '6', 'train_class_major', 'info', 'N', '0', 'admin', CURRENT_TIMESTAMP, '桥隧检修专业' +-- WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '6'); +-- +-- 3.3 查看当前字典配置 +-- SELECT dict_sort, dict_label, dict_value, status +-- FROM sys_dict_data +-- WHERE dict_type = 'train_class_major' +-- ORDER BY dict_sort; diff --git a/build_ui/src/api/train/historyRegStat.js b/build_ui/src/api/train/historyRegStat.js new file mode 100644 index 000000000..5ccc790a5 --- /dev/null +++ b/build_ui/src/api/train/historyRegStat.js @@ -0,0 +1,47 @@ +import request from '@/utils/request' + +/** + * 历史报到统计接口(独立模块)。 + * + * 口径:报到状态取自参训档案 enrollmentStatus,报到时间取自 joinTime; + * 已报到 = enrollmentStatus in (1,4) 且 joinTime 非空;不包含耗时类指标。 + */ + +// 总览统计 +export function getHistoryStatOverview(query) { + return request({ + url: '/train/regdata/history-stat/overview', + method: 'get', + params: query + }) +} + +// 分组汇总(batch/dept/major/place/gender/ageBand/dutyLevel/enrollSource/accommodation/firstTime) +export function listHistoryStatGroups(query) { + return request({ + url: '/train/regdata/history-stat/groups', + method: 'get', + params: query + }) +} + +// 报到时间分布(day/week/month/hour) +export function listHistoryStatTimes(query) { + return request({ + url: '/train/regdata/history-stat/times', + method: 'get', + params: query + }) +} + +// 报到明细(分页 + 异常筛选) +export function listHistoryStatDetails(query) { + return request({ + url: '/train/regdata/history-stat/details', + method: 'get', + params: query + }) +} + +// 导出报到明细(xlsx),页面通过 @/utils/request 的 download 方法调用: +// download('/train/regdata/history-stat/details/export', params, '历史报到明细.xlsx') diff --git a/build_ui/src/views/train/historyregdata/index.vue b/build_ui/src/views/train/historyregdata/index.vue index 3cec0a46d..7c2a2e56d 100644 --- a/build_ui/src/views/train/historyregdata/index.vue +++ b/build_ui/src/views/train/historyregdata/index.vue @@ -1,114 +1,199 @@