完善报到记录、历史报到统计;

This commit is contained in:
buliping committed 2026-11-11 11:29:37 +08:00
1 parent 42c2bcd4b5
commit ff129170eb
19 files changed
+4384 -449

No files matched your search

@@ -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(独立模块)。
*
* <p>路由前缀 /train/regdata/history-stat,与既有 {@code /train/regdata/history/**} 互不影响;
* 权限沿用既有报到查询/导出权限,不新增权限点,避免影响现有角色授权。</p>
*
* <p>口径:报到状态取自 train_enrollment.enrollment_status,报到时间取自 join_time;
* 已报到 = status in (1,4) 且 join_time 非空;不使用 train_class_report。</p>
*
* @author ruoyi
*/
@RestController
@RequestMapping("/train/regdata/history-stat")
public class TrainRegHistoryStatController extends BaseController
{
@Autowired
private ITrainRegHistoryStatService trainRegHistoryStatService;
/**
* 总览统计。
*
* <p>GET /train/regdata/history-stat/overview</p>
*/
@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)。
*
* <p>GET /train/regdata/history-stat/groups?groupBy=dept&pageNum=1&pageSize=100</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/groups")
public TableDataInfo groups(RegHistoryStatQuery query)
{
RegHistoryPageVO<RegHistoryGroupVO> page = trainRegHistoryStatService.selectGroupPage(query);
TableDataInfo table = getDataTable(page.getRows());
table.setTotal(page.getTotal());
return table;
}
/**
* 报到时间分布(day/week/month/hour)。
*
* <p>GET /train/regdata/history-stat/times?granularity=day</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/times")
public AjaxResult times(RegHistoryStatQuery query)
{
List<?> buckets = trainRegHistoryStatService.selectTimeBuckets(query);
return success(buckets);
}
/**
* 报到明细(分页,支持异常筛选)。
*
* <p>GET /train/regdata/history-stat/details?exceptionType=all&pageNum=1&pageSize=10</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:list')")
@GetMapping("/details")
public TableDataInfo details(RegHistoryStatQuery query)
{
RegHistoryPageVO<RegHistoryDetailVO> page = trainRegHistoryStatService.selectDetailPage(query);
TableDataInfo table = getDataTable(page.getRows());
table.setTotal(page.getTotal());
return table;
}
/**
* 导出报到明细。
*
* <p>POST /train/regdata/history-stat/details/export</p>
*/
@PreAuthorize("@ss.hasPermi('train:report:export')")
@Log(title = "历史报到统计明细", businessType = BusinessType.EXPORT)
@PostMapping("/details/export")
public void exportDetails(HttpServletResponse response, RegHistoryStatQuery query)
{
List<RegHistoryDetailVO> list = trainRegHistoryStatService.selectDetailList(query);
ExcelUtil<RegHistoryDetailVO> util = new ExcelUtil<RegHistoryDetailVO>(RegHistoryDetailVO.class);
util.exportExcel(response, list, "历史报到明细");
}
}
@@ -0,0 +1,196 @@
package com.ruoyi.train.domain.query;
import java.util.Date;
/**
* 历史报到统计查询条件。
*
* <p>该对象仅服务于新增的历史报到统计(/train/regdata/history-stat),
* 与既有 {@code /train/regdata/history/*} 报表相互独立,不参与旧统计口径。</p>
*
* @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;
}
}
@@ -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;
/**
* 历史报到明细行。
*
* <p>不含到训耗时、耗时分位、平均到训时刻;报到时间统一取 join_time。</p>
*
* @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;
}
}
@@ -0,0 +1,428 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 历史报到统计事实行(只读投影)。
*
* <p>数据来源严格限定为 train_enrollment + train_class_plan + train_student + sys_dept,
* 不引用 train_class_report,报到状态取自 enrollment_status,报到时间取自 join_time。</p>
*
* @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;
}
}
@@ -0,0 +1,301 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
/**
* 历史报到统计分组汇总行。
*
* <p>不含到训耗时、耗时分位、平均到训时刻三类指标。</p>
*
* @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;
}
}
@@ -0,0 +1,237 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
/**
* 历史报到统计总览(不含到训耗时类指标)。
*
* <p>口径:已报到 = enrollment_status in (1,4) 且 join_time 非空;
* 应报到 = enrollment_status in (0,1,2,4)。</p>
*
* @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;
}
}
@@ -0,0 +1,54 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 内存分页结果(用于历史报到统计的分组表与明细表)。
*
* <p>统计口径要求先全量聚合再分页,因此不复用 PageHelper 的 SQL 分页。</p>
*
* @param <T> 行类型
* @author ruoyi
*/
public class RegHistoryPageVO<T> implements Serializable
{
private static final long serialVersionUID = 1L;
/** 当前页数据 */
private List<T> rows = new ArrayList<>();
/** 总条数 */
private long total = 0L;
public RegHistoryPageVO()
{
}
public RegHistoryPageVO(List<T> rows, long total)
{
this.rows = rows;
this.total = total;
}
public List<T> getRows()
{
return rows;
}
public void setRows(List<T> rows)
{
this.rows = rows;
}
public long getTotal()
{
return total;
}
public void setTotal(long total)
{
this.total = total;
}
}
@@ -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;
}
}
@@ -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。
*
* <p>仅查询 train_enrollment / train_class_plan / train_student / sys_dept,
* 不访问 train_class_report,也不写入任何表。</p>
*
* @author ruoyi
*/
public interface TrainRegHistoryStatMapper
{
/**
* 查询历史报到统计事实行(扁平投影,聚合在 Java 侧完成)。
*
* @param query 过滤条件
* @return 事实行集合
*/
List<RegHistoryFactVO> selectHistoryFacts(@Param("q") RegHistoryStatQuery query);
}
@@ -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;
/**
* 历史报到统计服务(独立于既有报到统计)。
*
* <p>统计口径:已报到 = enrollment_status in (1,4) 且 join_time 非空;
* 不使用 train_class_report;不计算耗时类指标。</p>
*
* @author ruoyi
*/
public interface ITrainRegHistoryStatService
{
/**
* 总览统计。
*
* @param query 过滤条件
* @return 总览
*/
RegHistoryOverviewVO selectOverview(RegHistoryStatQuery query);
/**
* 分组汇总(内存分页)。
*
* @param query 过滤条件与分组维度
* @return 分组行分页结果
*/
RegHistoryPageVO<RegHistoryGroupVO> selectGroupPage(RegHistoryStatQuery query);
/**
* 报到时间分布。
*
* @param query 过滤条件与时间粒度
* @return 时间桶列表
*/
List<RegHistoryTimeBucketVO> selectTimeBuckets(RegHistoryStatQuery query);
/**
* 报到明细(内存分页)。
*
* @param query 过滤条件与异常筛选
* @return 明细分页结果
*/
RegHistoryPageVO<RegHistoryDetailVO> selectDetailPage(RegHistoryStatQuery query);
/**
* 报到明细全量(导出用,不分页)。
*
* @param query 过滤条件与异常筛选
* @return 明细列表
*/
List<RegHistoryDetailVO> selectDetailList(RegHistoryStatQuery query);
}
@@ -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;
/**
* 历史报到统计服务实现。
*
* <p>与既有 {@code TrainRegDataServiceImpl} 完全隔离:独立 Mapper、独立口径、独立接口,
* 不修改任何既有统计方法的返回值。</p>
*
* @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<RegHistoryGroupVO> selectGroupPage(RegHistoryStatQuery query)
{
RegHistoryStatQuery normalized = normalize(query);
RegHistoryStatCalculator.Dataset dataset = loadDataset(normalized);
List<RegHistoryGroupVO> groups = RegHistoryStatCalculator.buildGroups(dataset.getRows(),
normalized.getGroupBy(), RegHistoryStatCalculator.DEFAULT_TARGET_RATE);
return paginate(groups, normalized.getPageNum(), normalized.getPageSize());
}
@Override
public List<RegHistoryTimeBucketVO> selectTimeBuckets(RegHistoryStatQuery query)
{
RegHistoryStatQuery normalized = normalize(query);
RegHistoryStatCalculator.Dataset dataset = loadDataset(normalized);
return RegHistoryStatCalculator.buildTimeBuckets(dataset.getRows(), normalized.getGranularity());
}
@Override
public RegHistoryPageVO<RegHistoryDetailVO> selectDetailPage(RegHistoryStatQuery query)
{
RegHistoryStatQuery normalized = normalize(query);
List<RegHistoryDetailVO> details = filterDetails(buildDetails(normalized), normalized.getExceptionType());
return paginate(details, normalized.getPageNum(), normalized.getPageSize());
}
@Override
public List<RegHistoryDetailVO> selectDetailList(RegHistoryStatQuery query)
{
RegHistoryStatQuery normalized = normalize(query);
return filterDetails(buildDetails(normalized), normalized.getExceptionType());
}
private List<RegHistoryDetailVO> buildDetails(RegHistoryStatQuery query)
{
RegHistoryStatCalculator.Dataset dataset = loadDataset(query);
return RegHistoryStatCalculator.buildDetails(dataset.getRows());
}
/**
* 查询事实行并完成过滤/去重。每次请求独立取数,不缓存、不共享状态。
*/
private RegHistoryStatCalculator.Dataset loadDataset(RegHistoryStatQuery query)
{
List<RegHistoryFactVO> 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<RegHistoryDetailVO> filterDetails(List<RegHistoryDetailVO> 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<RegHistoryDetailVO> 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 <T> RegHistoryPageVO<T> paginate(List<T> all, int pageNum, int pageSize)
{
int total = all == null ? 0 : all.size();
if (total == 0)
{
return new RegHistoryPageVO<>(new ArrayList<T>(), 0L);
}
int from = (pageNum - 1) * pageSize;
if (from >= total)
{
return new RegHistoryPageVO<>(new ArrayList<T>(), total);
}
int to = Math.min(total, from + pageSize);
return new RegHistoryPageVO<>(new ArrayList<>(all.subList(from, to)), total);
}
}
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainRegHistoryStatMapper">
<!--
历史报到统计事实行(只读)。
口径:报到状态取自 train_enrollment.enrollment_status,报到时间取自 train_enrollment.join_time;
送培单位取自 train_student.dept_name(不关联 sys_dept,避免依赖部门表录入);
不引用 train_class_report;放弃(3)记录在 SQL 与 Java 两层均被排除。
聚合与比率计算全部在 RegHistoryStatCalculator 中完成,保证 MySQL / KingbaseES 行为一致。
-->
<select id="selectHistoryFacts" parameterType="com.ruoyi.train.domain.query.RegHistoryStatQuery"
resultType="com.ruoyi.train.domain.vo.RegHistoryFactVO">
select e.enrollment_id as enrollmentId,
e.plan_id as planId,
coalesce(p.class_name, p.plan_name) as className,
p.plan_name as planName,
p.plan_status as planStatus,
p.start_time as startTime,
p.end_time as endTime,
p.report_starttime as reportStartTime,
p.report_deadline as reportDeadline,
p.create_time as planCreateTime,
p.class_major as classMajor,
p.train_address as trainAddress,
p.class_teacher as classTeacher,
p.host_dept_id as hostDeptId,
p.manager as manager,
e.student_id as studentId,
s.s_name as studentName,
s.person_no as personNo,
s.s_gender as gender,
s.s_birth as birth,
s.duty_level as dutyLevel,
s.dept_id as deptId,
s.dept_name as deptName,
s.status as studentStatus,
s.del_flag as studentDelFlag,
e.source_type as enrollSource,
e.accommodation_required as accommodationRequired,
e.enrollment_status as enrollmentStatus,
e.join_time as joinTime,
e.create_time as enrollCreateTime,
e.update_time as updateTime
from train_enrollment e
join train_class_plan p on p.plan_id = e.plan_id
left join train_student s on s.s_id = e.student_id
<where>
p.del_flag = '0'
and p.plan_status &lt;&gt; 3
and e.enrollment_status &lt;&gt; 3
<!-- 未开始批次按"生效状态"判定:持久化 plan_status=0 但已过开班时间的批次
在业务上已属进行中,与 train_class_plan 列表接口的 effectivePlanStatusPolicy 保持一致,
否则会整批漏统计(列表能看到、统计查不到)。 -->
<if test="q.includeNotStarted == null or !q.includeNotStarted">
and (case when p.plan_status = 0 and p.start_time &lt;= CURRENT_TIMESTAMP then 1 else p.plan_status end) &lt;&gt; 0
</if>
<if test="q.planId != null and q.planId != ''">
and e.plan_id = #{q.planId}
</if>
<if test="q.deptId != null">
and s.dept_id = #{q.deptId}
</if>
<if test="q.classMajor != null">
and p.class_major = #{q.classMajor}
</if>
<if test="q.startTime != null">
and p.start_time &gt;= #{q.startTime}
</if>
<if test="q.endTime != null">
and p.start_time &lt;= #{q.endTime}
</if>
</where>
order by p.start_time desc, e.enrollment_id asc
limit #{q.limit}
</select>
</mapper>
@@ -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 契约测试。
*
* <p>保护三条硬约束:</p>
* <ol>
* <li>只读取 train_enrollment / train_class_plan / train_student / sys_dept;</li>
* <li>严禁引用 train_class_report;</li>
* <li>报到时间只使用 join_time,且放弃(3)在 SQL 层被排除。</li>
* </ol>
*
* @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 &lt;&gt; 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 &lt;= current_timestamp then 1"));
assertFalse("不得再直接排除持久化状态 0",
xml.contains("and p.plan_status &lt;&gt; 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();
}
}
}
@@ -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;
/**
* 历史报到统计口径单元测试(纯计算,不依赖数据库)。
*
* <p>覆盖:已报到判定、放弃过滤、去重优先级、报到率分母、请假口径、
* 迟到基准回退、分组计数、时间分桶、明细异常标记、参数归一化。</p>
*
* @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<RegHistoryFactVO> 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<RegHistoryFactVO> 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<RegHistoryGroupVO> 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<RegHistoryGroupVO> 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<RegHistoryGroupVO> 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<RegHistoryGroupVO> 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<RegHistoryGroupVO> 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<RegHistoryFactVO> 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<RegHistoryTimeBucketVO> 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<RegHistoryTimeBucketVO> 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<RegHistoryDetailVO> 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<RegHistoryGroupVO> 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());
}
}
@@ -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<host> -p<port> -U<user> -d<db> -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;
+47
View File
@@ -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')
+438 -353
View File
@@ -1,114 +1,199 @@
<template>
<div v-loading="loading" class="app-container class-history-regdata">
<div v-loading="statLoading" class="app-container class-history-regdata">
<!-- 顶部标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">历史报到统计</h2>
<span class="topbar-sub">数据取自参训记录(train_enrollment),报到状态以 enrollment_status 为准</span>
<!--<span class="topbar-sub">数据取自参训记录(train_enrollment),报到状态以 enrollment_status 为准</span>-->
</div>
<div>
<el-button size="small" icon="el-icon-download" @click="handleExport">导出统计报表</el-button>
<el-button size="small" icon="el-icon-download" @click="handleStatExport">导出报到明细</el-button>
</div>
</div>
<!-- 统计卡 -->
<div class="rh-stats">
<div class="rh-stat">
<div class="rh-stat-label">累计报到人次</div>
<div class="rh-stat-value dark">{{ totalReportedText }}</div>
</div>
<div class="rh-stat">
<div class="rh-stat-label">平均报到率</div>
<div class="rh-stat-value green">{{ summary.avgRate }}%</div>
</div>
<div class="rh-stat">
<div class="rh-stat-label">平均到训耗时</div>
<div class="rh-stat-value dark">{{ summary.avgCostMinutes }} <span class="rh-unit">分钟</span></div>
</div>
<div class="rh-stat">
<div class="rh-stat-label">最早到达 / 最晚到达</div>
<div class="rh-stat-value dark small">{{ summary.earliest || '—' }} / {{ summary.latest || '—' }}</div>
</div>
</div>
<!-- 中部两栏 -->
<div class="rh-middle">
<!-- 到训时间分布 -->
<div class="rh-panel rh-chart-panel">
<div class="rh-panel-head">
<span class="rh-panel-title">到训时间分布</span>
<span class="rh-panel-extra">{{ summary.batchCount || 0 }} 个批次汇总 · 单位:人</span>
</div>
<div v-if="hourlyBars.length" class="rh-chart">
<div v-for="bar in hourlyBars" :key="bar.hour" class="rh-bar-col">
<span class="rh-bar-value">{{ bar.count }}</span>
<div class="rh-bar-track">
<div class="rh-bar" :class="{ peak: bar.peak }" :style="{ height: bar.percent + '%' }" />
</div>
<span class="rh-bar-label">{{ bar.hour }}</span>
</div>
</div>
<el-empty v-else :image-size="60" description="暂无统计数据" />
</div>
<!-- 筛选:紧贴统计结果,查询条件与分组汇总相邻显示 -->
<el-form :inline="true" size="small" class="hs-filter">
<el-form-item label="开班日期">
<el-date-picker
v-model="statDateRange"
type="daterange"
value-format="yyyy-MM-dd"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px"
/>
</el-form-item>
<el-form-item label="培训班">
<el-select v-model="statQuery.planId" clearable filterable placeholder="全部批次" style="width: 200px">
<el-option
v-for="plan in planOptions"
:key="plan.planId"
:label="plan.className || plan.planName"
:value="plan.planId"
/>
</el-select>
</el-form-item>
<el-form-item label="分组维度">
<el-select v-model="statQuery.groupBy" style="width: 120px" @change="handleStatGroupChange">
<el-option v-for="item in groupOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="时间粒度">
<el-select v-model="statQuery.granularity" style="width: 96px" @change="loadStatTimes">
<el-option v-for="item in granularityOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="明细筛选">
<el-select v-model="statQuery.exceptionType" style="width: 112px" @change="handleStatDetailSearch">
<el-option v-for="item in exceptionOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="loadStatAll">查询</el-button>
<el-button icon="el-icon-refresh-left" @click="handleStatReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 各批次报到率趋势 -->
<div class="rh-panel rh-trend-panel">
<div class="rh-panel-head">
<span class="rh-panel-title">各批次报到率趋势</span>
<span class="rh-panel-extra">近 {{ trendLimit }} 个批次 · 目标线 {{ targetRate }}%</span>
</div>
<div v-if="trendBars.length" class="rh-trend-body">
<div class="rh-target-line" :style="{ bottom: targetLinePos + 'px' }" />
<div v-for="(b, idx) in trendBars" :key="b.batchNo + '-' + idx" class="rh-trend-col">
<span class="rh-trend-value">{{ b.rate }}%</span>
<div class="rh-trend-bar" :style="{ height: b.height + 'px', background: b.color }" />
<span class="rh-trend-label" :title="b.batchName">{{ b.batchNo }}</span>
</div>
</div>
<el-empty v-else :image-size="60" description="暂无批次数据" />
<!-- 分组汇总 -->
<div class="hs-panel hs-group-panel">
<div class="hs-panel-head">
<span class="hs-panel-title">分组汇总</span>
<span class="hs-panel-extra">{{ statGroupLabelText }} · 共 {{ statGroupTotal }} 组{{ statGroupHint }}</span>
</div>
</div>
<!-- 各送培单位报到情况 -->
<div class="rh-panel">
<div class="rh-panel-head">
<span class="rh-panel-title">各送培单位报到情况</span>
<span class="rh-panel-extra">按平均报到率降序</span>
</div>
<el-table :data="unitPaged" size="small" empty-text="暂无送培单位数据">
<el-table-column label="送培单位" prop="unitName" min-width="220" show-overflow-tooltip />
<el-table-column label="累计送培" width="110" align="center">
<template slot-scope="scope">{{ scope.row.totalSent }} 人</template>
<el-table :data="statGroups" size="small" empty-text="暂无统计数据">
<el-table-column label="分组" min-width="170" show-overflow-tooltip>
<template slot-scope="scope">{{ statGroupLabel(scope.row) }}</template>
</el-table-column>
<el-table-column label="已报到" width="100" align="center">
<template slot-scope="scope">{{ scope.row.reported }} 人</template>
<el-table-column label="批次数" width="80" align="center">
<template slot-scope="scope">{{ scope.row.batchCount }}</template>
</el-table-column>
<el-table-column label="平均报到率" width="110" align="center">
<template slot-scope="scope">{{ scope.row.avgRate }}%</template>
<el-table-column label="应报到" width="80" align="center">
<template slot-scope="scope">{{ scope.row.expectedCount }}</template>
</el-table-column>
<el-table-column label="报到率" min-width="200">
<el-table-column label="已报到" width="80" align="center">
<template slot-scope="scope">{{ scope.row.reportedCount }}</template>
</el-table-column>
<el-table-column label="报到人数" width="90" align="center">
<template slot-scope="scope">{{ scope.row.reportedPersonCount }}</template>
</el-table-column>
<el-table-column label="报到率" width="160">
<template slot-scope="scope">
<div class="rh-bar">
<div class="rh-bar-fill" :style="{ width: Math.min(100, Number(scope.row.avgRate) || 0) + '%', background: barColor(scope.row.avgRate) }" />
<div class="hs-rate">
<div class="hs-rate-bar">
<div class="hs-rate-fill" :style="{ width: Math.min(100, scope.row.reportRate) + '%', background: statRateColor(scope.row.reportRate) }" />
</div>
<span class="hs-rate-text">{{ scope.row.reportRate }}%</span>
</div>
</template>
</el-table-column>
<el-table-column label="平均到训时间" prop="avgArriveTime" width="120" align="center">
<template slot-scope="scope">{{ scope.row.avgArriveTime || '—' }}</template>
<el-table-column label="迟到" width="70" align="center">
<template slot-scope="scope">{{ scope.row.lateCount }}</template>
</el-table-column>
<el-table-column label="评价" width="90" align="center">
<el-table-column label="准点率" width="86" align="center">
<template slot-scope="scope">{{ scope.row.onTimeRate }}%</template>
</el-table-column>
<el-table-column label="待报到 / 请假" width="118" align="center">
<template slot-scope="scope">{{ scope.row.waitingCount }} / {{ scope.row.leaveCount }}</template>
</el-table-column>
<el-table-column label="标记" width="190">
<template slot-scope="scope">
<el-tag :type="levelType(scope.row.avgRate)" size="mini" effect="plain">{{ levelText(scope.row.avgRate) }}</el-tag>
<el-tag v-if="scope.row.targetMet === false" size="mini" type="warning" effect="plain">未达标</el-tag>
<el-tag v-if="scope.row.outlierFlag" size="mini" type="danger" effect="plain">离群</el-tag>
<el-tag v-if="scope.row.concentratedFlag" size="mini" effect="plain">集中报到</el-tag>
<el-tag v-if="scope.row.volatilityFlag" size="mini" type="warning" effect="plain">波动</el-tag>
</template>
</el-table-column>
</el-table>
<div class="rh-pager">
<span class="rh-pager-total">共 {{ unitTotal }} 条</span>
<div class="hs-pager">
<el-pagination
small
background
layout="prev, pager, next"
:total="unitTotal"
:page-size="unitQuery.pageSize"
:current-page.sync="unitQuery.pageNum"
layout="total, prev, pager, next"
:total="statGroupTotal"
:page-size="statGroupPage.pageSize"
:current-page.sync="statGroupPage.pageNum"
@current-change="loadStatGroups"
/>
</div>
</div>
<!-- 总览 + 时间分布:同一行左右分布,窄屏自动堆叠 -->
<div class="hs-summary-row">
<!-- 总览卡 -->
<div class="hs-cards">
<div v-for="card in statCards" :key="card.label" class="hs-card">
<div class="hs-card-label">{{ card.label }}</div>
<div class="hs-card-value" :class="card.tone">
{{ card.value }}<span v-if="card.unit" class="hs-unit">{{ card.unit }}</span>
</div>
<div class="hs-card-extra">{{ card.extra }}</div>
</div>
</div>
<!-- 时间分布 -->
<div class="hs-panel hs-chart-panel">
<div class="hs-panel-head">
<span class="hs-panel-title">报到时间分布</span>
<span class="hs-panel-extra">{{ statGranularityText }} · 仅统计已报到记录{{ statTimes.length > STAT_TIME_LIMIT ? '(展示最近 ' + STAT_TIME_LIMIT + ' 个区间)' : '' }}</span>
</div>
<div v-if="statTimeBars.length" class="hs-chart">
<div v-for="bar in statTimeBars" :key="bar.periodKey" class="hs-bar-col">
<span class="hs-bar-value">{{ bar.reportedCount }}</span>
<div class="hs-bar-track">
<div class="hs-bar" :class="{ peak: bar.peak }" :style="{ height: bar.height + '%' }" />
</div>
<span class="hs-bar-label" :title="bar.periodLabel">{{ bar.periodLabel }}</span>
</div>
</div>
<el-empty v-else :image-size="60" description="暂无报到时间数据" />
</div>
</div>
<!-- 报到明细 -->
<div class="hs-panel">
<div class="hs-panel-head">
<span class="hs-panel-title">报到明细</span>
<span class="hs-panel-extra">共 {{ statDetailTotal }} 条</span>
</div>
<el-table :data="statDetails" size="small" empty-text="暂无明细数据">
<el-table-column label="批次" prop="className" min-width="150" show-overflow-tooltip />
<el-table-column label="学员" width="140">
<template slot-scope="scope">
<span>{{ scope.row.studentName || '—' }}</span>
<span v-if="scope.row.personNo" class="hs-muted">({{ scope.row.personNo }})</span>
</template>
</el-table-column>
<el-table-column label="送培单位" prop="deptName" min-width="140" show-overflow-tooltip />
<el-table-column label="性别" prop="gender" width="66" align="center" />
<el-table-column label="年龄段" prop="ageBand" width="104" align="center" />
<el-table-column label="参训状态" width="96" align="center">
<template slot-scope="scope">
<el-tag :type="statStatusType(scope.row.enrollmentStatus)" size="mini" effect="plain">{{ scope.row.statusText }}</el-tag>
</template>
</el-table-column>
<el-table-column label="报到时间" prop="joinTime" width="158" align="center" />
<el-table-column label="报到情况" width="118" align="center">
<template slot-scope="scope">
<span :class="{ 'hs-late': scope.row.lateFlag }">{{ scope.row.lateText }}</span>
</template>
</el-table-column>
<el-table-column label="异常" min-width="170" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.exceptionText" class="hs-abnormal">{{ scope.row.exceptionText }}</span>
<span v-else-if="scope.row.overdueFlag" class="hs-abnormal">逾期未报到</span>
<span v-else class="hs-muted">—</span>
</template>
</el-table-column>
</el-table>
<div class="hs-pager">
<el-pagination
small
background
layout="total, prev, pager, next"
:total="statDetailTotal"
:page-size="statDetailPage.pageSize"
:current-page.sync="statDetailPage.pageNum"
@current-change="loadStatDetails"
/>
</div>
</div>
@@ -118,263 +203,262 @@
<script>
import { listPlan } from '@/api/train/plan'
import {
REPORTED_STATUS,
averageClock,
buildHourly,
fetchPlanEnrollments,
filterEffective,
formatClock,
parseDateTime,
rateOf
} from '@/utils/enrollmentReport'
getHistoryStatOverview,
listHistoryStatGroups,
listHistoryStatTimes,
listHistoryStatDetails
} from '@/api/train/historyRegStat'
import { download } from '@/utils/request'
// 趋势柱状图固定量程与尺寸(px)
const SCALE_MIN = 70
const SCALE_MAX = 100
const BAR_AREA = 160
const LABEL_H = 22
// 趋势图展示的最近批次数
const TREND_LIMIT = 8
// 历史统计纳入的最大计划数(避免一次性拉取过多计划的参训记录)
const PLAN_LIMIT = 100
// 时间分布最多展示的区间数
const STAT_TIME_LIMIT = 40
export default {
name: 'HistoryRegData',
// 培训专业字典:统计展示与班级维护共用同一份配置
dicts: ['train_class_major'],
data() {
return {
loading: false,
targetRate: 90,
trendLimit: TREND_LIMIT,
// 统计汇总
summary: { totalReported: 0, avgRate: 0, avgCostMinutes: 0, earliest: '', latest: '', hourly: [], batchCount: 0 },
// 批次报到率趋势(已按最近批次正序)
trendList: [],
// 各送培单位
unitList: [],
unitQuery: { pageNum: 1, pageSize: 10 }
STAT_TIME_LIMIT,
statLoading: false,
statErrorNotified: false,
statDateRange: [],
statQuery: {
planId: undefined,
groupBy: 'batch',
granularity: 'day',
exceptionType: 'all'
},
statOverview: {},
statGroups: [],
statGroupTotal: 0,
statGroupPage: { pageNum: 1, pageSize: 20 },
statTimes: [],
statDetails: [],
statDetailTotal: 0,
statDetailPage: { pageNum: 1, pageSize: 10 },
planOptions: [],
groupOptions: [
{ value: 'batch', label: '培训班' },
{ value: 'dept', label: '送培单位' },
{ value: 'major', label: '专业' },
{ value: 'place', label: '培训地点' },
{ value: 'gender', label: '性别' },
{ value: 'ageBand', label: '年龄段' },
{ value: 'accommodation', label: '住宿需求' },
{ value: 'firstTime', label: '首次/复训' }
],
granularityOptions: [
{ value: 'day', label: '按日' },
{ value: 'week', label: '按周' },
{ value: 'month', label: '按月' },
{ value: 'hour', label: '按时段' }
],
exceptionOptions: [
{ value: 'all', label: '全部' },
{ value: 'late', label: '迟到' },
{ value: 'overdue', label: '逾期未报到' },
{ value: 'inconsistent', label: '状态时间不一致' },
{ value: 'lagging', label: '状态滞后' },
{ value: 'timeConflict', label: '时间倒挂' },
{ value: 'abnormal', label: '全部异常' }
]
}
},
computed: {
/** 累计报到人次(千分位) */
totalReportedText() {
return String(this.summary.totalReported || 0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
/** 总览卡 */
statCards() {
const o = this.statOverview || {}
return [
{ label: '应报到', value: o.expectedCount || 0, unit: '人次', tone: 'dark', extra: `覆盖 ${o.batchCount || 0} 个批次` },
{ label: '已报到', value: o.reportedCount || 0, unit: '人次', tone: 'green', extra: `去重人数 ${o.reportedPersonCount || 0}` },
{ label: '报到率', value: o.reportRate || 0, unit: '%', tone: 'blue', extra: `出勤口径 ${o.attendanceRate || 0}% · 目标 ${o.targetRate || 0}%` },
{ label: '达标批次', value: `${o.targetMetBatchCount || 0} / ${o.batchCount || 0}`, tone: 'dark', extra: '批次报到率达到目标' },
{ label: '迟到', value: o.lateCount || 0, unit: '人次', tone: 'orange', extra: `迟到率 ${o.lateRate || 0}% · 准点率 ${o.onTimeRate || 0}%` },
{ label: '待报到 / 逾期', value: `${o.waitingCount || 0} / ${o.waitingOverdueCount || 0}`, tone: 'dark', extra: `请假 ${o.leaveCount || 0} · 状态不一致 ${o.inconsistentCount || 0}` }
]
},
/** 到训时间分布柱状数据(纯 CSS 柱状图) */
hourlyBars() {
const list = this.summary.hourly || []
const max = Math.max.apply(null, list.map(h => h.count).concat([1]))
return list.map(h => ({
hour: h.hour,
count: h.count,
percent: Math.round(h.count / max * 100),
peak: h.count === max && h.count > 0
statGroupLabelText() {
const hit = this.groupOptions.find(item => item.value === this.statQuery.groupBy)
return hit ? hit.label : '培训班'
},
/** 分组口径提示:性别与专业需明确编码/字典来源,避免统计项产生歧义 */
statGroupHint() {
if (this.statQuery.groupBy === 'gender') {
return ' · 编码 0=男、1=女、2=未知'
}
if (this.statQuery.groupBy === 'major') {
return ' · 名称取自字典「培训专业」'
}
return ''
},
statGranularityText() {
const hit = this.granularityOptions.find(item => item.value === this.statQuery.granularity)
return hit ? hit.label : '按日'
},
statTimeBars() {
const list = (this.statTimes || []).slice(-STAT_TIME_LIMIT)
const max = Math.max.apply(null, list.map(item => item.reportedCount).concat([1]))
return list.map(item => ({
periodKey: item.periodKey,
periodLabel: item.periodLabel,
reportedCount: item.reportedCount,
peak: item.peak,
height: Math.round(item.reportedCount / max * 100)
}))
},
/** 批次报到率趋势柱数据 */
trendBars() {
return (this.trendList || []).map(b => {
const rate = Number(b.rate) || 0
const height = Math.round(Math.min(BAR_AREA, Math.max(40, (rate - SCALE_MIN) / (SCALE_MAX - SCALE_MIN) * BAR_AREA)))
return { batchNo: b.batchNo, batchName: b.batchName, rate, height, color: this.trendColor(rate) }
})
},
/** 目标线距面板底部的像素位置 */
targetLinePos() {
const ratio = (this.targetRate - SCALE_MIN) / (SCALE_MAX - SCALE_MIN)
return Math.round(LABEL_H + Math.min(1, Math.max(0, ratio)) * BAR_AREA)
},
/** 送培单位分页数据 */
unitTotal() {
return this.unitList.length
},
unitPaged() {
const start = (this.unitQuery.pageNum - 1) * this.unitQuery.pageSize
return this.unitList.slice(start, start + this.unitQuery.pageSize)
}
},
created() {
this.loadData()
this.loadPlanOptions()
this.loadStatAll()
},
methods: {
/** 拉取全部培训计划的 train_enrollment 数据并做前端聚合 */
loadData() {
this.loading = true
listPlan({ pageNum: 1, pageSize: PLAN_LIMIT }).then(res => {
const plans = (res.rows || []).slice().sort((a, b) => {
return String(b.startTime || '').localeCompare(String(a.startTime || ''))
})
return this.fetchAllPlans(plans)
}).then(groups => {
this.buildSummary(groups || [])
/** 培训班下拉(仅用于筛选,失败不影响统计) */
loadPlanOptions() {
listPlan({ pageNum: 1, pageSize: 200 }).then(res => {
this.planOptions = res.rows || []
}).catch(() => {
this.buildSummary([])
}).finally(() => {
this.loading = false
this.planOptions = []
})
},
/**
* 分批拉取各计划的参训记录,限制并发(每批 4 个计划),避免一次性打出过多请求。
* @param {Array} plans 培训计划列表
* @returns {Promise<Array>} [{ plan, rows }]
*/
fetchAllPlans(plans) {
const groups = []
const CONCURRENCY = 4
const run = index => {
if (index >= plans.length) return Promise.resolve(groups)
const chunk = plans.slice(index, index + CONCURRENCY)
return Promise.all(chunk.map(plan => {
return fetchPlanEnrollments(plan.planId)
.then(rows => ({ plan: plan, rows: rows }))
.catch(() => ({ plan: plan, rows: [] }))
})).then(results => {
results.forEach(item => groups.push(item))
return run(index + CONCURRENCY)
})
/** 公共查询参数 */
statBaseParams() {
return {
planId: this.statQuery.planId || undefined,
startDate: this.statDateRange && this.statDateRange[0] ? this.statDateRange[0] : undefined,
endDate: this.statDateRange && this.statDateRange[1] ? this.statDateRange[1] : undefined
}
return run(0)
},
/**
* 汇总各批次参训记录:
* 累计报到人次、平均报到率、平均到训耗时、最早/最晚到达、到训时间分布、批次趋势、送培单位。
*/
buildSummary(groups) {
let totalReported = 0
let rateSum = 0
let rateCount = 0
let costSum = 0
let costCount = 0
let earliestMs = null
let latestMs = null
const reportedRows = []
const trendAll = []
const unitMap = {}
const unitStat = name => {
if (!unitMap[name]) {
unitMap[name] = { unitName: name, totalSent: 0, reported: 0, minutes: [] }
}
return unitMap[name]
}
groups.forEach(group => {
const plan = group.plan || {}
const effective = filterEffective(group.rows)
const reported = effective.filter(row => Number(row.enrollmentStatus) === REPORTED_STATUS)
totalReported += reported.length
if (effective.length > 0) {
rateSum += rateOf(reported.length, effective.length)
rateCount += 1
}
trendAll.push({
batchNo: plan.className || plan.planName || plan.planId,
batchName: plan.planName || '',
rate: rateOf(reported.length, effective.length),
startTime: plan.startTime || ''
})
effective.forEach(row => {
unitStat(row.deptName || '未分配单位').totalSent += 1
})
// 到训耗时基准:报到开始时间,缺失时退化为开班时间
const base = parseDateTime(plan.reportStarttime) || parseDateTime(plan.startTime)
reported.forEach(row => {
reportedRows.push(row)
const unit = unitStat(row.deptName || '未分配单位')
unit.reported += 1
const join = parseDateTime(row.joinTime)
if (!join) return
const ms = join.getTime()
if (earliestMs === null || ms < earliestMs) earliestMs = ms
if (latestMs === null || ms > latestMs) latestMs = ms
unit.minutes.push(join.getHours() * 60 + join.getMinutes())
if (base) {
const diff = Math.round((ms - base.getTime()) / 60000)
if (diff >= 0) {
costSum += diff
costCount += 1
}
}
})
/** 全量刷新(总览 + 分组 + 时间分布 + 明细) */
loadStatAll() {
this.statLoading = true
this.statGroupPage.pageNum = 1
this.statDetailPage.pageNum = 1
Promise.all([
this.loadStatOverview(),
this.loadStatGroups(),
this.loadStatTimes(),
this.loadStatDetails()
]).finally(() => {
this.statLoading = false
})
const hourly = buildHourly(reportedRows)
this.summary = {
totalReported: totalReported,
avgRate: rateCount ? Math.round(rateSum / rateCount * 10) / 10 : 0,
avgCostMinutes: costCount ? Math.round(costSum / costCount) : 0,
earliest: earliestMs === null ? '' : formatClock(new Date(earliestMs)),
latest: latestMs === null ? '' : formatClock(new Date(latestMs)),
hourly: hourly,
batchCount: rateCount
}
// 趋势:按开班时间倒序取最近 N 个批次,再正序展示
this.trendList = trendAll
.slice()
.sort((a, b) => String(b.startTime).localeCompare(String(a.startTime)))
.slice(0, TREND_LIMIT)
.reverse()
// 送培单位:按平均报到率降序
this.unitList = Object.keys(unitMap).map(key => {
const unit = unitMap[key]
return {
unitName: unit.unitName,
totalSent: unit.totalSent,
reported: unit.reported,
avgRate: rateOf(unit.reported, unit.totalSent),
avgArriveTime: averageClock(unit.minutes)
}
}).sort((a, b) => b.avgRate - a.avgRate)
this.unitQuery.pageNum = 1
},
/** 趋势柱颜色:超过目标越多颜色越深 */
trendColor(rate) {
if (rate >= 95) return '#1d39c4'
if (rate >= 93) return '#2b6bff'
if (rate >= this.targetRate) return '#5b8dff'
return '#adc4f9'
loadStatOverview() {
return getHistoryStatOverview(this.statBaseParams()).then(res => {
this.statOverview = res.data || {}
this.statErrorNotified = false
}).catch(() => {
this.statOverview = {}
this.notifyStatError()
})
},
/** 单位报到率进度条颜色 */
barColor(rate) {
const r = Number(rate) || 0
if (r >= 92) return '#10b981'
if (r >= this.targetRate) return '#2b6bff'
return '#f79009'
loadStatGroups() {
return listHistoryStatGroups({
...this.statBaseParams(),
groupBy: this.statQuery.groupBy,
pageNum: this.statGroupPage.pageNum,
pageSize: this.statGroupPage.pageSize
}).then(res => {
this.statGroups = res.rows || []
this.statGroupTotal = Number(res.total) || 0
}).catch(() => {
this.statGroups = []
this.statGroupTotal = 0
this.notifyStatError()
})
},
/** 评价标签文案:>=95 优,>=90 良,否则待改进 */
levelText(rate) {
const r = Number(rate) || 0
if (r >= 95) return '优'
if (r >= this.targetRate) return '良'
return '待改进'
loadStatTimes() {
return listHistoryStatTimes({
...this.statBaseParams(),
granularity: this.statQuery.granularity
}).then(res => {
this.statTimes = res.data || []
}).catch(() => {
this.statTimes = []
this.notifyStatError()
})
},
levelType(rate) {
const r = Number(rate) || 0
if (r >= 95) return 'success'
if (r >= this.targetRate) return ''
return 'warning'
loadStatDetails() {
return listHistoryStatDetails({
...this.statBaseParams(),
exceptionType: this.statQuery.exceptionType,
pageNum: this.statDetailPage.pageNum,
pageSize: this.statDetailPage.pageSize
}).then(res => {
this.statDetails = res.rows || []
this.statDetailTotal = Number(res.total) || 0
}).catch(() => {
this.statDetails = []
this.statDetailTotal = 0
this.notifyStatError()
})
},
/** 导出统计报表(送培单位维度,字段与页面一致) */
handleExport() {
if (!this.unitList.length) {
this.$modal.msgWarning('暂无可导出的统计数据')
handleStatGroupChange() {
this.statGroupPage.pageNum = 1
this.loadStatGroups()
},
handleStatDetailSearch() {
this.statDetailPage.pageNum = 1
this.loadStatDetails()
},
handleStatReset() {
this.statDateRange = []
this.statQuery.planId = undefined
this.statQuery.groupBy = 'batch'
this.statQuery.granularity = 'day'
this.statQuery.exceptionType = 'all'
this.loadStatAll()
},
/** 导出报到明细(xlsx) */
handleStatExport() {
download(
'/train/regdata/history-stat/details/export',
{ ...this.statBaseParams(), exceptionType: this.statQuery.exceptionType },
`历史报到明细_${new Date().getTime()}.xlsx`
)
},
/** 统计接口失败提示(四个并发请求只提示一次),避免接口异常被误判为"无数据" */
notifyStatError() {
if (this.statErrorNotified) {
return
}
const headers = ['送培单位', '累计送培', '已报到', '平均报到率(%)', '平均到训时间', '评价']
const lines = [headers.join(',')]
this.unitList.forEach(item => {
const cells = [
item.unitName,
item.totalSent,
item.reported,
item.avgRate,
item.avgArriveTime,
this.levelText(item.avgRate)
]
lines.push(cells.map(cell => `"${String(cell === null || cell === undefined ? '' : cell).replace(/"/g, '""')}"`).join(','))
})
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `历史报到统计_${new Date().getTime()}.csv`
link.click()
URL.revokeObjectURL(link.href)
this.statErrorNotified = true
this.$modal.msgError('报到统计接口调用失败,请确认服务已部署且账号具备报到查询权限')
},
/** 分组展示名:专业维度以字典 train_class_major 为准,其余维度沿用后端标签 */
statGroupLabel(row) {
if (this.statQuery.groupBy === 'gender') {
// 性别编码:0=男、1=女、2=未知;展示时保留编码,避免只看到数字无法对应含义
return row.groupLabel === row.groupKey ? row.groupLabel : `${row.groupLabel}(${row.groupKey})`
}
if (this.statQuery.groupBy !== 'major') {
return row.groupLabel
}
if (row.groupKey === '-1') {
return '未设置专业'
}
const dictList = (this.dict && this.dict.type && this.dict.type.train_class_major) || []
const hit = dictList.find(item => String(item.value) === String(row.groupKey))
return hit ? hit.label : `${row.groupLabel}(字典未配置)`
},
statRateColor(rate) {
const value = Number(rate) || 0
if (value >= 95) {
return '#10b981'
}
if (value >= 90) {
return '#2b6bff'
}
return '#f79009'
},
statStatusType(status) {
if (status === 1 || status === 4) {
return 'success'
}
if (status === 2) {
return 'info'
}
return 'warning'
}
}
}
@@ -382,45 +466,46 @@ export default {
<style scoped>
.class-history-regdata { background: #f5f7fa; }
/* 统计卡 */
.rh-stats { display: flex; background: #fff; border-radius: 10px; padding: 18px 0; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.rh-stat { flex: 1; padding: 0 24px; }
.rh-stat + .rh-stat { border-left: 1px solid #f0f2f7; }
.rh-stat-label { font-size: 12px; color: #8a94a6; margin-bottom: 8px; }
.rh-stat-value { font-size: 26px; font-weight: 700; line-height: 1; }
.rh-stat-value.small { font-size: 20px; }
.rh-stat-value.dark { color: #1f2329; }
.rh-stat-value.green { color: #10b981; }
.rh-unit { font-size: 12px; font-weight: 400; color: #8a94a6; margin-left: 2px; }
/* 中部两栏 */
.rh-middle { display: flex; gap: 16px; margin-bottom: 16px; align-items: stretch; }
.rh-chart-panel { flex: 0 0 48%; min-width: 0; }
.rh-trend-panel { flex: 1; min-width: 0; }
@media (max-width: 1200px) { .rh-middle { flex-direction: column; } .rh-chart-panel { flex: none; } }
/* 通用面板 */
.rh-panel { background: #fff; border-radius: 10px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.rh-panel-head { display: flex; align-items: center; margin-bottom: 12px; }
.rh-panel-title { font-size: 15px; font-weight: 700; color: #1f2329; }
.rh-panel-extra { margin-left: auto; font-size: 12px; color: #8a94a6; }
/* 到训时间分布柱状图 */
.rh-chart { display: flex; align-items: flex-end; gap: 6px; padding: 10px 4px 0; min-height: 220px; }
.rh-bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; min-width: 0; }
.rh-bar-value { font-size: 11px; color: #4e5969; margin-bottom: 4px; }
.rh-bar-track { width: 100%; max-width: 34px; height: 150px; display: flex; align-items: flex-end; justify-content: center; }
.rh-bar { width: 22px; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #6ea0ff 0%, #3f7dff 100%); transition: height .3s; }
.rh-bar.peak { background: linear-gradient(180deg, #ffb14d 0%, #f79009 100%); }
.rh-bar-label { margin-top: 6px; font-size: 10px; color: #a2aab8; white-space: nowrap; }
/* 批次报到率趋势 */
.rh-trend-body { position: relative; display: flex; align-items: flex-end; justify-content: space-around; gap: 10px; padding: 6px 8px 2px; }
.rh-target-line { position: absolute; left: 0; right: 0; border-top: 1px dashed #f79009; opacity: .55; pointer-events: none; }
.rh-trend-col { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; min-width: 0; }
.rh-trend-value { font-size: 12px; font-weight: 600; color: #1f2329; margin-bottom: 6px; }
.rh-trend-bar { width: 34px; border-radius: 12px; transition: height .3s; }
.rh-trend-label { margin-top: 8px; font-size: 11px; color: #a2aab8; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 单位报到率进度条 */
.rh-bar { height: 8px; border-radius: 4px; background: #eef0f5; overflow: hidden; }
.rh-bar-fill { height: 100%; border-radius: 4px; transition: width .3s; }
/* 分页 */
.rh-pager { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.rh-pager-total { font-size: 12px; color: #8a94a6; }
/* 筛选条紧贴分组汇总,查询条件与结果相邻 */
.hs-filter { background: #fff; border-radius: 10px; padding: 14px 16px 2px; margin-bottom: 8px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
/* 总览卡与时间分布同行:左侧 2 列网格固定占比,右侧图表占满剩余宽度 */
.hs-summary-row { display: flex; gap: 14px; margin-bottom: 14px; align-items: stretch; }
.hs-cards { flex: 0 0 40%; min-width: 320px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 12px; }
.hs-card { min-width: 0; display: flex; flex-direction: column; justify-content: center; background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.hs-panel.hs-chart-panel { flex: 1 1 auto; min-width: 0; margin-bottom: 0; display: flex; flex-direction: column; }
/* 窄屏:上下堆叠,总览卡回落为三列/两列,保证卡片文字不被挤压 */
@media (max-width: 1400px) { .hs-cards { flex: 0 0 44%; } }
@media (max-width: 1200px) {
.hs-summary-row { flex-direction: column; }
.hs-cards { flex: none; min-width: 0; grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 768px) {
.hs-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.hs-card-label { font-size: 12px; color: #8a94a6; margin-bottom: 8px; }
.hs-card-value { font-size: 24px; font-weight: 700; line-height: 1; color: #1f2329; }
.hs-card-value.green { color: #10b981; }
.hs-card-value.blue { color: #2b6bff; }
.hs-card-value.orange { color: #f79009; }
.hs-unit { font-size: 12px; font-weight: 400; color: #8a94a6; margin-left: 3px; }
.hs-card-extra { margin-top: 8px; font-size: 11px; color: #a2aab8; min-height: 14px; }
.hs-panel { background: #fff; border-radius: 10px; padding: 14px 18px; margin-bottom: 14px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.hs-panel-head { display: flex; align-items: center; margin-bottom: 12px; }
.hs-panel-title { font-size: 15px; font-weight: 700; color: #1f2329; }
.hs-panel-extra { margin-left: auto; font-size: 12px; color: #8a94a6; }
.hs-rate { display: flex; align-items: center; gap: 8px; }
.hs-rate-bar { flex: 1; height: 8px; border-radius: 4px; background: #eef0f5; overflow: hidden; }
.hs-rate-fill { height: 100%; border-radius: 4px; transition: width .3s; }
.hs-rate-text { width: 44px; text-align: right; font-size: 12px; color: #4e5969; }
.hs-chart { flex: 1; display: flex; align-items: flex-end; gap: 6px; padding: 10px 4px 0; min-height: 200px; overflow-x: auto; }
.hs-bar-col { flex: 1 0 26px; display: flex; flex-direction: column; align-items: center; min-width: 26px; }
.hs-bar-value { font-size: 11px; color: #4e5969; margin-bottom: 4px; }
.hs-bar-track { width: 100%; max-width: 26px; height: 140px; display: flex; align-items: flex-end; justify-content: center; }
.hs-bar { width: 18px; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #6ea0ff 0%, #3f7dff 100%); }
.hs-bar.peak { background: linear-gradient(180deg, #ffb14d 0%, #f79009 100%); }
.hs-bar-label { margin-top: 6px; font-size: 10px; color: #a2aab8; max-width: 46px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hs-pager { display: flex; justify-content: flex-end; margin-top: 10px; }
.hs-muted { color: #a2aab8; }
.hs-late { color: #f79009; }
.hs-abnormal { color: #f04438; }
</style>
+131 -96
View File
@@ -7,7 +7,6 @@
<!--<span class="topbar-sub">数据取自参训记录(train_enrollment),报到状态以 enrollment_status 为准</span>-->
</div>
<div class="rd-topbar-actions">
<el-button size="small" icon="el-icon-download" @click="handleExport">导出报到名册</el-button>
<el-button type="primary" size="small" icon="el-icon-plus" @click="goStartRegister">启动班级报到</el-button>
</div>
</div>
@@ -24,83 +23,44 @@
<span v-if="!planLoading && !visiblePlans.length" class="rd-empty-tab">暂无未结束的培训班级</span>
</div>
<!-- 统计卡 -->
<div class="rd-stats">
<div class="rd-stat">
<div class="rd-stat-label">应报到</div>
<div class="rd-stat-value dark">{{ summary.expected }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">已报到</div>
<div class="rd-stat-value green">{{ summary.reported }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">待报到</div>
<div class="rd-stat-value red">{{ summary.notReported }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">报到率</div>
<div class="rd-stat-value green">{{ summary.rate }}%</div>
</div>
</div>
<!-- 中部两栏 -->
<div class="rd-middle">
<!-- 待报到学员 -->
<div class="rd-panel rd-unreported">
<div class="rd-panel-head">
<span class="rd-panel-title">待报到学员</span>
<span class="rd-badge-red">{{ notReportedTotal }} 人待处理</span>
<!-- 统计概览与报到时间分布(同一行左右分布) -->
<div class="rd-summary-row">
<!-- 统计卡:2×2 网格,窄屏下自动回落为单行/两列 -->
<div class="rd-stats">
<div class="rd-stat">
<div class="rd-stat-label">应报到</div>
<div class="rd-stat-value dark">{{ summary.expected }} <span class="rd-unit">人</span></div>
</div>
<el-table :data="notReportedPaged" size="small" class="rd-table" empty-text="该班级暂无待报到学员">
<el-table-column label="学员" min-width="130">
<template slot-scope="scope">
<div class="rd-student">
<span class="rd-avatar" :style="{ background: avatarColor(scope.row.studentName) }">{{ (scope.row.studentName || '').charAt(0) }}</span>
<div>
<div class="rd-student-name">{{ scope.row.studentName }}</div>
<div class="rd-student-code">{{ scope.row.personNo || scope.row.studentId }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="选派单位" prop="deptName" min-width="100" show-overflow-tooltip />
<el-table-column label="联系电话" prop="phone" width="115" />
<el-table-column label="报到状态" width="86" align="center">
<template slot-scope="scope">
<el-tag type="danger" size="mini">{{ statusTag(scope.row.enrollmentStatus).text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" prop="remark" min-width="90" show-overflow-tooltip />
</el-table>
<div class="rd-pager">
<span class="rd-pager-total">共 {{ notReportedTotal }} 条 · 每页 {{ notQuery.pageSize }} 条</span>
<el-pagination
small
background
layout="prev, pager, next"
:total="notReportedTotal"
:page-size="notQuery.pageSize"
:current-page.sync="notQuery.pageNum"
/>
<div class="rd-stat">
<div class="rd-stat-label">已报到</div>
<div class="rd-stat-value green">{{ summary.reported }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">待报到</div>
<div class="rd-stat-value red">{{ summary.notReported }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">报到率</div>
<div class="rd-stat-value green">{{ summary.rate }}%</div>
</div>
</div>
<!-- 报到时间分布 -->
<!-- 报到日期分布:按天聚合,一根柱子代表一天 -->
<div class="rd-panel rd-chart-panel">
<div class="rd-panel-head">
<span class="rd-panel-title">报到时间分布</span>
<span class="rd-head-right rd-panel-extra">按入班报到时间 join_time 统计</span>
<span class="rd-panel-title">报到日期分布</span>
<span class="rd-panel-extra rd-head-right">按报到日期统计 · 单位:人</span>
</div>
<div v-if="hourlyBars.length" class="rd-chart">
<div v-for="bar in hourlyBars" :key="bar.hour" class="rd-bar-col">
<div v-if="dailyBars.length" class="rd-chart">
<div v-for="bar in dailyBars" :key="bar.date" class="rd-bar-col">
<span class="rd-bar-value">{{ bar.count }}</span>
<div class="rd-bar-track">
<div class="rd-bar" :class="{ peak: bar.peak }" :style="{ height: bar.percent + '%' }" />
</div>
<span class="rd-bar-label">{{ bar.hour }}</span>
<span class="rd-bar-label" :title="bar.date">{{ bar.label }}</span>
</div>
</div>
<el-empty v-else :image-size="60" description="暂无报到数据" />
<div v-if="summary.peakNote" class="rd-chart-note">{{ summary.peakNote }}</div>
<div v-if="dailyNote" class="rd-chart-note">{{ dailyNote }}</div>
</div>
</div>
@@ -108,10 +68,14 @@
<div class="rd-panel">
<div class="rd-panel-head">
<span class="rd-panel-title">报到名册 · {{ currentPlanLabel }}</span>
<span class="rd-head-right">
<span class="rd-panel-extra rd-head-right">{{ rosterRange }}</span>
</div>
<!-- 筛选与操作 -->
<div class="rd-toolbar">
<div class="rd-toolbar-left">
<el-input
v-model="rosterFilter.studentName"
size="mini"
size="small"
class="rd-filter-item"
placeholder="学员姓名"
clearable
@@ -119,7 +83,7 @@
/>
<el-select
v-model="rosterFilter.enrollmentStatus"
size="mini"
size="small"
class="rd-filter-item"
placeholder="全部状态"
clearable
@@ -127,8 +91,10 @@
>
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<span class="rd-panel-extra">{{ rosterRange }}</span>
</span>
</div>
<div class="rd-toolbar-right">
<el-button size="small" icon="el-icon-download" @click="handleExport">导出报到名册</el-button>
</div>
</div>
<el-table
:data="rosterPaged"
@@ -159,7 +125,7 @@
<el-table-column label="报到时间" prop="joinTime" width="160" align="center">
<template slot-scope="scope">{{ scope.row.joinTime || '—' }}</template>
</el-table-column>
<!-- <el-table-column label="结课时间" prop="completeTime" width="160" align="center">
<!-- <el-table-column label="结课时间" prop="completeTime" width="160" align="center">
<template slot-scope="scope">{{ scope.row.completeTime || '—' }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="90" show-overflow-tooltip>
@@ -210,14 +176,31 @@ import { listPlan } from '@/api/train/plan'
import { ENROLLMENT_STATUS_OPTIONS, formatEnrollmentStatusTag } from '@/views/train/checkin/attendancePolicy'
import {
WAITING_STATUS,
REPORTED_STATUS,
filterEffective,
fetchPlanEnrollments,
summarizeEnrollments
summarizeEnrollments,
parseDateTime
} from '@/utils/enrollmentReport'
// 培训计划状态字典:0未开始 1进行中 2已结束 3已取消
// 顶部班级标签只保留"未结束"的班级,即排除已结束(2)
const ENDED_PLAN_STATUS = 2
// 每日柱状图最多补零展示的天数,跨度过大时退化为只展示有数据的日期,避免柱子过多
const MAX_DAILY_BARS = 62
const DAY_MS = 24 * 60 * 60 * 1000
/** Date -> yyyy-MM-dd(本地时区,避免 toISOString 的时区偏移) */
function formatDateKey(date) {
const pad = num => (num < 10 ? '0' + num : String(num))
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
}
/** yyyy-MM-dd -> 本地 Date(零点) */
function dateFromKey(key) {
const parts = String(key).split('-')
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]))
}
export default {
name: 'ClassRegData',
@@ -302,17 +285,58 @@ export default {
const start = (this.rosterQuery.pageNum - 1) * this.rosterQuery.pageSize
return this.rosterSorted.slice(start, start + this.rosterQuery.pageSize)
},
/** 报到时间分布柱状数据(纯 CSS 柱状图) */
hourlyBars() {
const list = this.summary.hourly || []
const max = Math.max.apply(null, list.map(h => h.count).concat([1]))
return list.map(h => ({
hour: h.hour,
count: h.count,
percent: Math.round(h.count / max * 100),
peak: h.count === max && h.count > 0
/**
* 报到日期分布柱状数据(纯 CSS 柱状图)。
* 先按 joinTime 的日期分组汇总,再按日期升序渲染,保证同一天只有一根柱子;
* 日期轴取首末报到日之间的连续区间,缺失日期补 0,视觉上保持连续。
*/
dailyBars() {
const counter = {}
this.effectiveList.forEach(row => {
if (Number(row.enrollmentStatus) !== REPORTED_STATUS) return
const date = parseDateTime(row.joinTime)
if (!date) return
const key = formatDateKey(date)
counter[key] = (counter[key] || 0) + 1
})
const keys = Object.keys(counter).sort()
if (!keys.length) return []
const start = dateFromKey(keys[0])
const end = dateFromKey(keys[keys.length - 1])
const totalDays = Math.round((end.getTime() - start.getTime()) / DAY_MS) + 1
const dates = []
if (totalDays > MAX_DAILY_BARS) {
// 跨度超出可展示区间时跳过缺失日期,但序列仍按时间升序
dates.push.apply(dates, keys)
} else {
const cursor = new Date(start.getTime())
while (cursor.getTime() <= end.getTime()) {
dates.push(formatDateKey(cursor))
cursor.setDate(cursor.getDate() + 1)
}
}
const counts = dates.map(key => counter[key] || 0)
const max = Math.max.apply(null, counts.concat([1]))
return dates.map((key, index) => ({
date: key,
label: key.slice(5),
count: counts[index],
percent: Math.round(counts[index] / max * 100),
peak: counts[index] === max && counts[index] > 0
}))
},
/** 报到日期峰值说明 */
dailyNote() {
const bars = this.dailyBars
if (!bars.length) return ''
let peak = bars[0]
bars.forEach(bar => {
if (bar.count > peak.count) peak = bar
})
if (!peak.count) return ''
const activeDays = bars.filter(bar => bar.count > 0).length
return '报到高峰日期 ' + peak.label + '(' + peak.count + ' 人)· 共 ' + activeDays + ' 天有报到记录'
},
/** 报到时间窗描述(取自培训计划) */
rosterRange() {
const plan = this.currentPlan
@@ -321,8 +345,8 @@ export default {
const start = fmt(plan.startTime)
const end = fmt(plan.endTime)
if (!start && !end) return ''
let text = start + ' ~ ' + end
if (plan.reportDeadline) text += ' · 报到截止 ' + fmt(plan.reportDeadline)
let text = '开班时间:' + start + ' ~ ' + end
if (plan.reportDeadline) text += ' · 报到截止:' + fmt(plan.reportDeadline)
return text
}
},
@@ -488,21 +512,32 @@ export default {
.rd-tab:hover { color: #2b6bff; border-color: #2b6bff; }
.rd-tab.active { background: #2b6bff; border-color: #2b6bff; color: #fff; font-weight: 600; }
.rd-empty-tab { font-size: 12px; color: #a2aab8; }
/* 统计卡 */
.rd-stats { display: flex; background: #fff; border-radius: 10px; padding: 18px 0; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.rd-stat { flex: 1; padding: 0 24px; }
.rd-stat + .rd-stat { border-left: 1px solid #f0f2f7; }
/* 统计概览与图表同行 */
.rd-summary-row { display: flex; gap: 16px; margin-bottom: 16px; align-items: stretch; }
/* 统计卡:2×2 网格,左栏固定占比,保证数字不被压缩 */
.rd-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 12px; flex: 0 0 38%; min-width: 280px; }
.rd-stat { min-width: 0; display: flex; flex-direction: column; justify-content: center; background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.rd-stat-label { font-size: 12px; color: #8a94a6; margin-bottom: 8px; }
.rd-stat-value { font-size: 26px; font-weight: 700; line-height: 1; }
.rd-stat-value { font-size: 26px; font-weight: 700; line-height: 1.1; white-space: nowrap; }
.rd-stat-value.dark { color: #1f2329; }
.rd-stat-value.green { color: #10b981; }
.rd-stat-value.red { color: #f04438; }
.rd-unit { font-size: 12px; font-weight: 400; color: #8a94a6; margin-left: 2px; }
/* 中部两栏 */
.rd-middle { display: flex; gap: 16px; margin-bottom: 16px; align-items: stretch; }
.rd-unreported { flex: 0 0 48%; min-width: 0; }
.rd-chart-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@media (max-width: 1200px) { .rd-middle { flex-direction: column; } .rd-unreported { flex: none; } }
/* 同行右侧图表:占满剩余宽度,底部间距交给行容器 */
.rd-panel.rd-chart-panel { flex: 1 1 auto; min-width: 0; margin-bottom: 0; display: flex; flex-direction: column; }
/* 窄屏:改为上下堆叠,统计卡回落为四列/两列,保证可读性 */
@media (max-width: 1400px) { .rd-stats { flex: 0 0 42%; } }
@media (max-width: 1200px) {
.rd-summary-row { flex-direction: column; }
.rd-stats { flex: none; grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 768px) {
.rd-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* 筛选与操作工具栏 */
.rd-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; padding: 10px 12px; background: #f7f9fc; border-radius: 8px; }
.rd-toolbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.rd-toolbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
/* 通用面板 */
.rd-panel { background: #fff; border-radius: 10px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(31,41,71,.06); margin-bottom: 16px; }
.rd-panel-head { display: flex; align-items: center; margin-bottom: 12px; }
@@ -510,7 +545,7 @@ export default {
.rd-badge-red { margin-left: 10px; font-size: 12px; color: #f04438; font-weight: 600; }
.rd-head-right { margin-left: auto; display: flex; align-items: center; }
.rd-panel-extra { font-size: 12px; color: #8a94a6; }
.rd-filter-item { width: 140px; margin-right: 8px; }
.rd-filter-item { width: 150px; }
/* 学员单元格 */
.rd-student { display: flex; align-items: center; }
.rd-avatar { flex: none; width: 30px; height: 30px; border-radius: 50%; color: #fff; font-size: 13px; display: flex; align-items: center; justify-content: center; margin-right: 8px; }
@@ -520,8 +555,8 @@ export default {
.rd-pager { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.rd-pager-total { font-size: 12px; color: #8a94a6; }
/* 纯 CSS 柱状图 */
.rd-chart { flex: 1; display: flex; align-items: flex-end; gap: 6px; padding: 10px 4px 0; min-height: 220px; }
.rd-bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; min-width: 0; }
.rd-chart { flex: 1; display: flex; align-items: flex-end; justify-content: center; gap: 6px; padding: 10px 4px 0; min-height: 200px; overflow-x: auto; }
.rd-bar-col { flex: 1 0 26px; max-width: 56px; min-width: 26px; display: flex; flex-direction: column; align-items: center; }
.rd-bar-value { font-size: 11px; color: #4e5969; margin-bottom: 4px; }
.rd-bar-track { width: 100%; max-width: 34px; height: 150px; display: flex; align-items: flex-end; justify-content: center; }
.rd-bar { width: 22px; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #6ea0ff 0%, #3f7dff 100%); transition: height .3s; }