完善报到记录、历史报到统计;
This commit is contained in:
1 parent
42c2bcd4b5
commit
ff129170eb
19 files changed
+4384
-449
No files matched your search
+110
@@ -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, "历史报到明细");
|
||||
}
|
||||
}
|
||||
+196
@@ -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;
|
||||
}
|
||||
}
|
||||
+396
@@ -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;
|
||||
}
|
||||
}
|
||||
+237
@@ -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;
|
||||
}
|
||||
}
|
||||
+104
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
+235
@@ -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);
|
||||
}
|
||||
}
|
||||
+1064
File diff suppressed because it is too large.
Load diff
@@ -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 <> 3
|
||||
and e.enrollment_status <> 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 <= CURRENT_TIMESTAMP then 1 else p.plan_status end) <> 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 >= #{q.startTime}
|
||||
</if>
|
||||
<if test="q.endTime != null">
|
||||
and p.start_time <= #{q.endTime}
|
||||
</if>
|
||||
</where>
|
||||
order by p.start_time desc, e.enrollment_id asc
|
||||
limit #{q.limit}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
+88
@@ -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 <> 3"));
|
||||
assertTrue(xml.contains("p.del_flag = '0'"));
|
||||
assertTrue("必须限制单次取数上限", xml.contains("limit #{q.limit}"));
|
||||
// 未开始批次必须按生效状态判定:持久化 0 且已过开班时间视为进行中,
|
||||
// 与列表接口 effectivePlanStatusPolicy 一致,避免整批漏统计。
|
||||
assertTrue("未开始批次必须按生效状态判定",
|
||||
xml.contains("case when p.plan_status = 0 and p.start_time <= current_timestamp then 1"));
|
||||
assertFalse("不得再直接排除持久化状态 0",
|
||||
xml.contains("and p.plan_status <> 0"));
|
||||
}
|
||||
|
||||
private String loadMapperXml() throws Exception
|
||||
{
|
||||
try (InputStream input = getClass().getResourceAsStream(MAPPER_XML))
|
||||
{
|
||||
assertNotNull("missing mapper " + MAPPER_XML, input);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[1024];
|
||||
int length;
|
||||
while ((length = input.read(buffer)) != -1)
|
||||
{
|
||||
output.write(buffer, 0, length);
|
||||
}
|
||||
String raw = new String(output.toByteArray(), StandardCharsets.UTF_8);
|
||||
// 剔除 XML 注释后再做约束断言,注释中的口径说明不属于 SQL 语句
|
||||
return raw.replaceAll("(?s)<!--.*?-->", "").toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
+324
@@ -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;
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user