feat(dorm): 一年级宿舍分配到班级由人文学院代理操作

复用请假代理学院配置,班级树对人文学院合并全校一年级、对他院隐藏一年级;分配/回收增加本学院床位与一年级代理写校验,不改班级真实归属。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
“wuzhen”andCursor committed 2026-10-12 16:51:42 +08:00
1 parent 103a53f6f3
commit 04d12bbce1
10 files changed
+597 -189

No files matched your search

+6
View File
@@ -113,6 +113,12 @@
<version>${revision}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,77 @@
package org.dromara.xg.helper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.service.ConfigService;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.stereotype.Component;
/**
* 一年级代理学院(人文学院)解析与宿舍分配权限判定。
* <p>
* 配置键复用请假:{@link FreshmanCollegeProxySupport#FRESHMAN_PROXY_COLLEGE_CONFIG_KEY}。
* 不修改班级真实 college_id,仅运行时判定操作权。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FreshmanCollegeProxyHelper {
private final ConfigService configService;
public int currentCohortYear() {
return FreshmanCollegeProxySupport.currentCohortYear();
}
public boolean isFreshmanByEnrollmentYear(Long enrollmentYear) {
return FreshmanCollegeProxySupport.isFreshmanByEnrollmentYear(enrollmentYear);
}
/**
* 读取代理学院;未配置或非法时抛业务异常(请假等强制场景)。
*/
public Long requireProxyCollegeId() {
Long proxyCollegeId = resolveProxyCollegeIdOrNull();
if (proxyCollegeId == null) {
throw new ServiceException("一年级请假代理学院未配置或无效,请在系统参数中配置 "
+ FreshmanCollegeProxySupport.FRESHMAN_PROXY_COLLEGE_CONFIG_KEY);
}
return proxyCollegeId;
}
/**
* 读取代理学院;未配置时返回 null(宿舍树降级为原逻辑,避免拖垮其它功能)。
*/
public Long resolveProxyCollegeIdOrNull() {
String configValue = configService.getConfigValue(
FreshmanCollegeProxySupport.FRESHMAN_PROXY_COLLEGE_CONFIG_KEY);
if (StringUtils.isBlank(configValue)) {
log.warn("系统参数 {} 未配置,一年级宿舍代理不生效",
FreshmanCollegeProxySupport.FRESHMAN_PROXY_COLLEGE_CONFIG_KEY);
return null;
}
try {
return Long.valueOf(configValue.trim());
} catch (NumberFormatException e) {
log.warn("系统参数 {} 配置无效: {}",
FreshmanCollegeProxySupport.FRESHMAN_PROXY_COLLEGE_CONFIG_KEY, configValue);
return null;
}
}
public boolean isProxyCollege(Long collegeId) {
Long proxyCollegeId = resolveProxyCollegeIdOrNull();
return proxyCollegeId != null && proxyCollegeId.equals(collegeId);
}
/**
* 当前学院用户是否可对指定班级做宿舍「分配到班级 / 回收班级分配」。
*/
public boolean canOperateClassForDormAllocation(Long operatorCollegeId,
Long classCollegeId,
Long enrollmentYear) {
return FreshmanCollegeProxySupport.canOperateClassForDormAllocation(
operatorCollegeId, classCollegeId, enrollmentYear, resolveProxyCollegeIdOrNull());
}
}
@@ -0,0 +1,70 @@
package org.dromara.xg.helper;
import java.time.LocalDate;
import java.time.ZoneId;
/**
* 一年级代理学院纯计算逻辑(无 Spring 依赖,便于单测)。
* <p>
* 学年切换日:每年 9 月 1 日(Asia/Shanghai)。与请假模块口径一致。
*/
public final class FreshmanCollegeProxySupport {
public static final ZoneId SHANGHAI_ZONE = ZoneId.of("Asia/Shanghai");
/** 与请假共用的系统参数键 */
public static final String FRESHMAN_PROXY_COLLEGE_CONFIG_KEY = "xg.studentLeave.freshmanProxyCollegeId";
private FreshmanCollegeProxySupport() {
}
/**
* 当前入学届别年份(9 月 1 日起算新一届)。
*/
public static int currentCohortYear(LocalDate today) {
LocalDate date = today != null ? today : LocalDate.now(SHANGHAI_ZONE);
return date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1;
}
/**
* 当前入学届别年份(按上海时区当天)。
*/
public static int currentCohortYear() {
return currentCohortYear(LocalDate.now(SHANGHAI_ZONE));
}
/**
* 是否一年级(入学年等于当前届)。入学年为空视为非一年级,避免误代理。
*/
public static boolean isFreshmanByEnrollmentYear(Long enrollmentYear, LocalDate today) {
if (enrollmentYear == null) {
return false;
}
return enrollmentYear.intValue() == currentCohortYear(today);
}
public static boolean isFreshmanByEnrollmentYear(Long enrollmentYear) {
return isFreshmanByEnrollmentYear(enrollmentYear, LocalDate.now(SHANGHAI_ZONE));
}
/**
* 宿舍「分配到班级」权限判定。
*
* @param operatorCollegeId 操作人所属二级学院
* @param classCollegeId 班级真实所属学院
* @param enrollmentYear 班级入学年
* @param proxyCollegeId 代理学院(未配置则为 null)
*/
public static boolean canOperateClassForDormAllocation(Long operatorCollegeId,
Long classCollegeId,
Long enrollmentYear,
Long proxyCollegeId) {
if (operatorCollegeId == null) {
return false;
}
if (isFreshmanByEnrollmentYear(enrollmentYear)) {
return proxyCollegeId != null && proxyCollegeId.equals(operatorCollegeId);
}
return classCollegeId != null && operatorCollegeId.equals(classCollegeId);
}
}
@@ -110,6 +110,14 @@ public interface XgStudentMsgMapper extends BaseMapperPlus<XgStudentMsg, XgStude
*/
List<Map<String, Object>> getCollegeStudentStatsByClass(@Param("secondaryCollegeId") Long secondaryCollegeId);
/**
* 按入学年查询全校一年级班级学生统计(宿舍分配到班级:代理学院可见)
*
* @param enrollmentYear 入学年(当前届)
* @return 学生统计信息列表(含 enrollment_year)
*/
List<Map<String, Object>> getFreshmanStudentStatsByClass(@Param("enrollmentYear") Integer enrollmentYear);
/**
* 查询二级学院下的学院床位分配统计信息(基于college_id统计)
*
@@ -1,5 +1,6 @@
package org.dromara.xg.service.impl;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.TableDataInfo;
@@ -57,6 +58,7 @@ import org.dromara.xg.mapper.XgClassMsgMapper;
import org.dromara.xg.domain.XgClassMsg;
import org.dromara.xg.domain.vo.XgDormBedDetailVo;
import org.dromara.xg.domain.bo.XgDormSelectiveAllocationBo;
import org.dromara.xg.helper.FreshmanCollegeProxyHelper;
/**
* 床位学生分配关系Service业务层处理
@@ -79,6 +81,7 @@ public class XgDormBedStudentServiceImpl implements IXgDormBedStudentService {
private final XgSysDeptMapper xgSysDeptMapper;
private final XgClassMsgMapper xgClassMsgMapper;
private final IXgStudentLeaveService xgStudentLeaveService;
private final FreshmanCollegeProxyHelper freshmanCollegeProxyHelper;
/**
* 查询床位学生分配关系
@@ -526,6 +529,13 @@ public class XgDormBedStudentServiceImpl implements IXgDormBedStudentService {
public Boolean batchRecallClassAllocation(XgDormBedBatchRecallBo bo) {
Date now = new Date();
Long loginUserId = LoginHelper.getUserId();
Long operatorCollegeId = getCurrentUserCollegeId();
if (operatorCollegeId == null) {
throw new ServiceException("无法识别当前用户所属学院,禁止回收班级分配");
}
if (bo.getBedIds() == null || bo.getBedIds().isEmpty()) {
throw new ServiceException("床位列表不能为空");
}
// 1) 查出这些床位当前有效的分配关系(status=0, deleted=0,并且已分配给班级)
LambdaQueryWrapper<XgDormBedStudent> activeAssignQuery = Wrappers.lambdaQuery();
@@ -539,11 +549,20 @@ public class XgDormBedStudentServiceImpl implements IXgDormBedStudentService {
return true; // 没有需要处理的数据
}
// 2) 更新分配关系:清空班级相关字段,但保留学院分配信息
// 1.1) 安全校验:仅可回收本学院床位;一年级班级仅代理学院可回收
assertOperatorCanManageBedAssignments(operatorCollegeId, activeAssignments);
Set<Long> allowedBedIds = activeAssignments.stream()
.map(XgDormBedStudent::getBedId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
// 2) 更新分配关系:清空班级相关字段,但保留学院分配信息(仅允许的床位)
LambdaUpdateWrapper<XgDormBedStudent> updateAssign = Wrappers.lambdaUpdate();
updateAssign.in(XgDormBedStudent::getBedId, bo.getBedIds())
updateAssign.in(XgDormBedStudent::getBedId, allowedBedIds)
.eq(XgDormBedStudent::getStatus, "0")
.eq(XgDormBedStudent::getDeleted, "0")
.eq(XgDormBedStudent::getCollegeId, operatorCollegeId)
.isNotNull(XgDormBedStudent::getClassId)
.set(XgDormBedStudent::getMajorId, null)
.set(XgDormBedStudent::getClassId, null)
@@ -588,6 +607,17 @@ public class XgDormBedStudentServiceImpl implements IXgDormBedStudentService {
@Transactional(rollbackFor = Exception.class)
public Boolean batchAllocateBedsToClass(XgDormBedBatchAllocateToClassBo bo) {
try {
Long operatorCollegeId = getCurrentUserCollegeId();
if (operatorCollegeId == null) {
throw new ServiceException("无法识别当前用户所属学院,禁止分配床位到班级");
}
if (bo.getBedIds() == null || bo.getBedIds().isEmpty()) {
throw new ServiceException("床位列表不能为空");
}
if (bo.getClassIds() == null || bo.getClassIds().isEmpty()) {
throw new ServiceException("班级列表不能为空");
}
// 1. 获取床位信息
List<XgDormBed> beds = xgDormBedMapper.selectBatchIds(bo.getBedIds());
@@ -610,7 +640,7 @@ public class XgDormBedStudentServiceImpl implements IXgDormBedStudentService {
.map(XgDormBed::getBedName)
.collect(java.util.stream.Collectors.toList());
throw new RuntimeException("以下床位已分配给班级,无法重复分配:" + String.join("、", allocatedBedNames));
throw new ServiceException("以下床位已分配给班级,无法重复分配:" + String.join("、", allocatedBedNames));
}
// 3. 检查床位是否已分配给学院(必须先分配给学院才能分配给班级)
@@ -618,12 +648,20 @@ public class XgDormBedStudentServiceImpl implements IXgDormBedStudentService {
LambdaQueryWrapper<XgDormBedStudent> collegeCheckWrapper = new LambdaQueryWrapper<>();
collegeCheckWrapper.in(XgDormBedStudent::getBedId, bo.getBedIds())
.isNotNull(XgDormBedStudent::getCollegeId) // 已分配给学院
.eq(XgDormBedStudent::getStatus, "0"); // 正常状态,未退宿
.eq(XgDormBedStudent::getStatus, "0") // 正常状态,未退宿
.eq(XgDormBedStudent::getDeleted, "0");
List<XgDormBedStudent> existingCollegeAllocations = baseMapper.selectList(collegeCheckWrapper);
if (existingCollegeAllocations.isEmpty()) {
throw new RuntimeException("选择的床位尚未分配给学院,请先分配给学院后再分配给班级");
throw new ServiceException("选择的床位尚未分配给学院,请先分配给学院后再分配给班级");
}
// 3.1 安全:只能操作已归属本学院的床位(防跨学院越权)
boolean hasForeignCollegeBed = existingCollegeAllocations.stream()
.anyMatch(item -> !operatorCollegeId.equals(item.getCollegeId()));
if (hasForeignCollegeBed) {
throw new ServiceException("只能将本学院已分配的床位再分配到班级");
}
// 4. 验证所有床位都已分配给学院
@@ -641,22 +679,31 @@ public class XgDormBedStudentServiceImpl implements IXgDormBedStudentService {
.map(XgDormBed::getBedName)
.collect(java.util.stream.Collectors.toList());
throw new RuntimeException("以下床位尚未分配给学院,无法分配给班级:" + String.join("、", unallocatedBedNames));
throw new ServiceException("以下床位尚未分配给学院,无法分配给班级:" + String.join("、", unallocatedBedNames));
}
log.info("床位预检验证通过:75个床位中,{}个已分配给学院,{}个未分配给学院",
collegeAllocatedBedIds.size(), unallocatedBedIds.size());
log.info("床位预检验证通过:请求{}个床位,{}个已分配给本学院",
bo.getBedIds().size(), collegeAllocatedBedIds.size());
// 5. 获取楼栋信息,用于获取校区ID和宿舍区ID
XgDormBuilding building = xgDormBuildingMapper.selectById(bo.getBuildingId());
if (building == null) {
throw new RuntimeException("楼栋信息不存在,buildingId: " + bo.getBuildingId());
throw new ServiceException("楼栋信息不存在,buildingId: " + bo.getBuildingId());
}
// 6. 查询班级信息,获取专业ID和学院ID
List<XgClassMsg> classes = xgClassMsgMapper.selectBatchIds(bo.getClassIds());
if (classes.size() != bo.getClassIds().size()) {
throw new RuntimeException("部分班级信息不存在,请检查班级ID");
throw new ServiceException("部分班级信息不存在,请检查班级ID");
}
// 6.1 安全:一年级仅代理学院可分;非一年级仅本学院可分
for (XgClassMsg clazz : classes) {
if (!freshmanCollegeProxyHelper.canOperateClassForDormAllocation(
operatorCollegeId, clazz.getCollegeId(), clazz.getEnrollmentYear())) {
throw new ServiceException("无权将床位分配给班级「" + clazz.getClassName()
+ "」(一年级由人文学院代理,其它年级仅本学院可操作)");
}
}
// 建立班级ID到班级信息的映射
@@ -1096,6 +1143,40 @@ public class XgDormBedStudentServiceImpl implements IXgDormBedStudentService {
}
}
/**
* 校验操作人是否可管理这些床位的班级分配(防跨学院 / 防非代理操作一年级)。
*/
private void assertOperatorCanManageBedAssignments(Long operatorCollegeId,
List<XgDormBedStudent> assignments) {
Set<Long> classIds = assignments.stream()
.map(XgDormBedStudent::getClassId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, XgClassMsg> classMap = classIds.isEmpty()
? Map.of()
: xgClassMsgMapper.selectBatchIds(classIds).stream()
.collect(Collectors.toMap(XgClassMsg::getId, c -> c, (a, b) -> a));
for (XgDormBedStudent assignment : assignments) {
if (!operatorCollegeId.equals(assignment.getCollegeId())) {
throw new ServiceException("只能回收本学院床位的班级分配");
}
Long classId = assignment.getClassId();
if (classId == null) {
continue;
}
XgClassMsg clazz = classMap.get(classId);
if (clazz == null) {
throw new ServiceException("床位关联班级不存在,禁止回收");
}
if (!freshmanCollegeProxyHelper.canOperateClassForDormAllocation(
operatorCollegeId, clazz.getCollegeId(), clazz.getEnrollmentYear())) {
throw new ServiceException("无权回收班级「" + clazz.getClassName()
+ "」的宿舍分配(一年级由人文学院代理)");
}
}
}
/**
* 获取分配给指定学院的宿舍楼ID列表
*/
@@ -1,7 +1,6 @@
package org.dromara.xg.service.impl;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.service.ConfigService;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.TableDataInfo;
@@ -46,7 +45,7 @@ import org.dromara.xg.domain.XgClassTeacherCounselorRel;
import org.dromara.xg.domain.XgClassMsg;
import org.dromara.xg.domain.XgDormBedStudent;
import org.dromara.xg.domain.XgSysDept;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.xg.helper.FreshmanCollegeProxyHelper;
import java.util.stream.Collectors;
import java.util.List;
@@ -57,8 +56,7 @@ import java.util.Calendar;
import java.util.Date;
import java.util.Set;
import java.util.HashSet;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.LinkedHashMap;
/**
* 学生信息Service业务层处理
@@ -71,14 +69,9 @@ import java.time.ZoneId;
@Service
public class XgStudentMsgServiceImpl implements IXgStudentMsgService {
/** 一年级请假代理学院(人文学院)dept_id,配置于 sys_config */
private static final String FRESHMAN_PROXY_COLLEGE_CONFIG_KEY = "xg.studentLeave.freshmanProxyCollegeId";
/** 请假二级学院审批角色 */
private static final String LEAVE_COLLEGE_APPROVER_ROLE_KEY = "xsqj-ejxyspr";
private static final ZoneId SHANGHAI_ZONE = ZoneId.of("Asia/Shanghai");
private final XgStudentMsgMapper baseMapper;
private final ISysUserService sysUserService;
private final SysDeptMapper sysDeptMapper;
@@ -88,7 +81,7 @@ public class XgStudentMsgServiceImpl implements IXgStudentMsgService {
private final XgClassMsgMapper xgClassMsgMapper;
private final XgDormBedStudentMapper xgDormBedStudentMapper;
private final XgSysDeptMapper xgSysDeptMapper;
private final ConfigService configService;
private final FreshmanCollegeProxyHelper freshmanCollegeProxyHelper;
/**
* 查询学生信息
@@ -1149,44 +1142,18 @@ public class XgStudentMsgServiceImpl implements IXgStudentMsgService {
throw new ServiceException("未找到该学生所在学院信息,无法提交请假");
}
if (!isFreshmanByEnrollmentYear(enrollmentYear)) {
if (!freshmanCollegeProxyHelper.isFreshmanByEnrollmentYear(enrollmentYear)) {
log.info("非一年级请假,使用学生本学院 - studentId: {}, enrollmentYear: {}, collegeId: {}",
studentId, enrollmentYear, ownCollegeId);
return ownCollegeId;
}
Long proxyCollegeId = resolveFreshmanProxyCollegeId();
Long proxyCollegeId = freshmanCollegeProxyHelper.requireProxyCollegeId();
log.info("一年级请假,使用代理学院 - studentId: {}, enrollmentYear: {}, proxyCollegeId: {}, ownCollegeId: {}",
studentId, enrollmentYear, proxyCollegeId, ownCollegeId);
return proxyCollegeId;
}
/**
* 按入学年判断是否一年级。学年切换日:每年 9 月 1 日(Asia/Shanghai)。
*/
private boolean isFreshmanByEnrollmentYear(Long enrollmentYear) {
LocalDate today = LocalDate.now(SHANGHAI_ZONE);
int currentCohortYear = today.getMonthValue() >= 9 ? today.getYear() : today.getYear() - 1;
return enrollmentYear.intValue() == currentCohortYear;
}
/**
* 从系统参数读取一年级请假代理学院 dept_id。
*/
private Long resolveFreshmanProxyCollegeId() {
String configValue = configService.getConfigValue(FRESHMAN_PROXY_COLLEGE_CONFIG_KEY);
if (StringUtils.isBlank(configValue)) {
throw new ServiceException("一年级请假代理学院未配置,请在系统参数中配置 "
+ FRESHMAN_PROXY_COLLEGE_CONFIG_KEY);
}
try {
return Long.valueOf(configValue.trim());
} catch (NumberFormatException e) {
throw new ServiceException("一年级请假代理学院配置无效(应为部门ID数字),请检查参数 "
+ FRESHMAN_PROXY_COLLEGE_CONFIG_KEY);
}
}
private Long parseCollegeId(String collegeIdStr, Long studentId) {
if (StringUtils.isBlank(collegeIdStr)) {
return null;
@@ -1365,165 +1332,60 @@ public class XgStudentMsgServiceImpl implements IXgStudentMsgService {
*/
public List<XgStudentTreeVo> getStudentTreeByClass(Long secondaryCollegeId) {
log.info("开始构建学生信息树(按床位分配统计),二级学院ID: {}", secondaryCollegeId);
List<XgStudentTreeVo> result = new ArrayList<>();
try {
// 1. 查询二级学院下的所有学院信息,并统计男女生人数(使用新的按班级床位分配统计方法)
List<Map<String, Object>> collegeStats = baseMapper.getCollegeStudentStatsByClass(secondaryCollegeId);
log.info("查询到 {} 个学院", collegeStats.size());
// 按学院分组
Map<String, List<Map<String, Object>>> collegeMap = collegeStats.stream()
.collect(Collectors.groupingBy(item -> {
Object collegeIdObj = item.get("college_id");
Long collegeId = collegeIdObj instanceof Long ? (Long) collegeIdObj
: Long.valueOf(collegeIdObj.toString());
String collegeName = (String) item.get("college_name");
return collegeId + "_" + collegeName;
}));
// 2. 构建学院节点
for (Map.Entry<String, List<Map<String, Object>>> collegeEntry : collegeMap.entrySet()) {
String collegeKey = collegeEntry.getKey();
List<Map<String, Object>> collegeMajors = collegeEntry.getValue();
String[] keyParts = collegeKey.split("_");
Long collegeId = Long.valueOf(keyParts[0]);
String collegeName = keyParts[1];
// 计算学院级别的统计信息
int collegeMaleTotal = 0;
int collegeFemaleTotal = 0;
int collegeMaleAssigned = 0;
int collegeFemaleAssigned = 0;
for (Map<String, Object> item : collegeMajors) {
collegeMaleTotal += ((Number) item.get("male_total")).intValue();
collegeFemaleTotal += ((Number) item.get("female_total")).intValue();
collegeMaleAssigned += ((Number) item.get("male_assigned")).intValue();
collegeFemaleAssigned += ((Number) item.get("female_assigned")).intValue();
}
XgStudentTreeVo collegeNode = new XgStudentTreeVo();
collegeNode.setId(collegeId.toString());
collegeNode.setLabel(collegeName);
collegeNode.setType("college");
collegeNode.setCollegeId(collegeId);
collegeNode.setCollegeName(collegeName);
collegeNode.setMaleTotal(collegeMaleTotal);
collegeNode.setFemaleTotal(collegeFemaleTotal);
collegeNode.setMaleAssigned(collegeMaleAssigned);
collegeNode.setFemaleAssigned(collegeFemaleAssigned);
collegeNode.setChildren(new ArrayList<>());
// 按专业分组
Map<String, List<Map<String, Object>>> majorMap = collegeMajors.stream()
.collect(Collectors.groupingBy(item -> {
Object majorIdObj = item.get("major_id");
Long majorId = majorIdObj instanceof Long ? (Long) majorIdObj
: Long.valueOf(majorIdObj.toString());
String majorName = (String) item.get("major_name");
return majorId + "_" + majorName;
}));
// 3. 构建专业节点
for (Map.Entry<String, List<Map<String, Object>>> majorEntry : majorMap.entrySet()) {
String majorKey = majorEntry.getKey();
List<Map<String, Object>> majorClasses = majorEntry.getValue();
String[] majorKeyParts = majorKey.split("_");
Long majorId = Long.valueOf(majorKeyParts[0]);
String majorName = majorKeyParts[1];
// 计算专业级别的统计信息
int majorMaleTotal = 0;
int majorFemaleTotal = 0;
int majorMaleAssigned = 0;
int majorFemaleAssigned = 0;
for (Map<String, Object> item : majorClasses) {
majorMaleTotal += ((Number) item.get("male_total")).intValue();
majorFemaleTotal += ((Number) item.get("female_total")).intValue();
majorMaleAssigned += ((Number) item.get("male_assigned")).intValue();
majorFemaleAssigned += ((Number) item.get("female_assigned")).intValue();
}
XgStudentTreeVo majorNode = new XgStudentTreeVo();
majorNode.setId(majorId.toString());
majorNode.setLabel(majorName);
majorNode.setType("major");
majorNode.setMajorId(majorId);
majorNode.setMajorName(majorName);
majorNode.setMaleTotal(majorMaleTotal);
majorNode.setFemaleTotal(majorFemaleTotal);
majorNode.setMaleAssigned(majorMaleAssigned);
majorNode.setFemaleAssigned(majorFemaleAssigned);
majorNode.setChildren(new ArrayList<>());
// 4. 构建班级节点
for (Map<String, Object> classData : majorClasses) {
Object classIdObj = classData.get("class_id");
Long classId = classIdObj instanceof Long ? (Long) classIdObj
: Long.valueOf(classIdObj.toString());
String className = (String) classData.get("class_name");
String grade = (String) classData.get("grade");
int classMaleTotal = ((Number) classData.get("male_total")).intValue();
int classFemaleTotal = ((Number) classData.get("female_total")).intValue();
int classMaleAssigned = ((Number) classData.get("male_assigned")).intValue();
int classFemaleAssigned = ((Number) classData.get("female_assigned")).intValue();
XgStudentTreeVo classNode = new XgStudentTreeVo();
classNode.setId(classId.toString());
classNode.setLabel(className);
classNode.setType("class");
classNode.setGrade(grade);
classNode.setClassId(classId);
classNode.setClassName(className);
classNode.setMaleTotal(classMaleTotal);
classNode.setFemaleTotal(classFemaleTotal);
classNode.setMaleAssigned(classMaleAssigned);
classNode.setFemaleAssigned(classFemaleAssigned);
classNode.setChildren(new ArrayList<>());
majorNode.getChildren().add(classNode);
}
collegeNode.getChildren().add(majorNode);
}
result.add(collegeNode);
}
log.info("成功构建学生信息树,共 {} 个学院", result.size());
return buildStudentTreeFromClassStats(collegeStats);
} catch (Exception e) {
log.error("构建学生信息树失败", e);
return new ArrayList<>();
}
return result;
}
/**
* 获取当前用户所在学院的学生信息树
*
* @return 学生信息树
* 获取当前用户所在学院的学生信息树(宿舍「分配到班级」专用入口)。
* <p>
* 一年级代理规则(不改班级真实学院归属):
* <ul>
* <li>代理学院(人文学院):本院全部班级 + 全校一年级班级</li>
* <li>其他学院:本院非一年级班级(一年级隐藏,防双头操作)</li>
* <li>代理学院未配置:保持原逻辑,仅本院班级</li>
* </ul>
*/
@Override
public List<XgStudentTreeVo> getCurrentUserStudentTree() {
try {
// 1. 获取当前用户的二级学院ID
Long collegeId = getCurrentUserCollegeId();
if (collegeId == null) {
log.warn("无法获取当前用户的二级学院信息");
return new ArrayList<>();
}
log.info("获取到当前用户的学院ID: {}", collegeId);
Long proxyCollegeId = freshmanCollegeProxyHelper.resolveProxyCollegeIdOrNull();
int cohortYear = freshmanCollegeProxyHelper.currentCohortYear();
log.info("宿舍班级树:userCollegeId={}, proxyCollegeId={}, cohortYear={}",
collegeId, proxyCollegeId, cohortYear);
// 2. 调用按床位分配统计的方法
return getStudentTreeByClass(collegeId);
if (proxyCollegeId == null) {
return getStudentTreeByClass(collegeId);
}
List<Map<String, Object>> collegeStats = baseMapper.getCollegeStudentStatsByClass(collegeId);
if (proxyCollegeId.equals(collegeId)) {
List<Map<String, Object>> freshmanStats =
baseMapper.getFreshmanStudentStatsByClass(cohortYear);
List<Map<String, Object>> merged =
mergeOwnCollegeWithFreshmanStats(collegeStats, freshmanStats);
log.info("代理学院班级树:本院行={}, 一年级行={}, 合并后={}",
collegeStats.size(), freshmanStats.size(), merged.size());
return buildStudentTreeFromClassStats(merged);
}
List<Map<String, Object>> nonFreshmanStats = collegeStats.stream()
.filter(item -> !isFreshmanStatRow(item, cohortYear))
.collect(Collectors.toList());
log.info("非代理学院班级树:本院行={}, 排除一年级后={}",
collegeStats.size(), nonFreshmanStats.size());
return buildStudentTreeFromClassStats(nonFreshmanStats);
} catch (Exception e) {
log.error("获取当前用户学院的学生信息树失败", e);
@@ -1531,6 +1393,161 @@ public class XgStudentMsgServiceImpl implements IXgStudentMsgService {
}
}
/**
* 本院统计 ∪ 全校一年级统计(按 class_id 去重,本院优先)。
*/
private List<Map<String, Object>> mergeOwnCollegeWithFreshmanStats(
List<Map<String, Object>> ownCollegeStats,
List<Map<String, Object>> freshmanStats) {
Map<String, Map<String, Object>> byClassId = new LinkedHashMap<>();
for (Map<String, Object> row : ownCollegeStats) {
String classKey = stringKey(row.get("class_id"));
if (classKey != null) {
byClassId.put(classKey, row);
}
}
for (Map<String, Object> row : freshmanStats) {
String classKey = stringKey(row.get("class_id"));
if (classKey != null) {
byClassId.putIfAbsent(classKey, row);
}
}
return new ArrayList<>(byClassId.values());
}
private boolean isFreshmanStatRow(Map<String, Object> row, int cohortYear) {
Object enrollmentYearObj = row.get("enrollment_year");
if (enrollmentYearObj == null) {
return false;
}
long enrollmentYear = enrollmentYearObj instanceof Number
? ((Number) enrollmentYearObj).longValue()
: Long.parseLong(enrollmentYearObj.toString());
return enrollmentYear == cohortYear;
}
private String stringKey(Object value) {
return value == null ? null : String.valueOf(value);
}
/**
* 将班级统计行构建为学院→专业→班级树(与历史 getStudentTreeByClass 结构一致)。
*/
private List<XgStudentTreeVo> buildStudentTreeFromClassStats(List<Map<String, Object>> collegeStats) {
List<XgStudentTreeVo> result = new ArrayList<>();
if (collegeStats == null || collegeStats.isEmpty()) {
return result;
}
Map<String, List<Map<String, Object>>> collegeMap = collegeStats.stream()
.collect(Collectors.groupingBy(item -> {
Object collegeIdObj = item.get("college_id");
Long collegeId = collegeIdObj instanceof Long ? (Long) collegeIdObj
: Long.valueOf(collegeIdObj.toString());
String collegeName = (String) item.get("college_name");
return collegeId + "_" + collegeName;
}, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry<String, List<Map<String, Object>>> collegeEntry : collegeMap.entrySet()) {
String collegeKey = collegeEntry.getKey();
List<Map<String, Object>> collegeMajors = collegeEntry.getValue();
int underscore = collegeKey.indexOf('_');
Long collegeId = Long.valueOf(collegeKey.substring(0, underscore));
String collegeName = collegeKey.substring(underscore + 1);
int collegeMaleTotal = 0;
int collegeFemaleTotal = 0;
int collegeMaleAssigned = 0;
int collegeFemaleAssigned = 0;
for (Map<String, Object> item : collegeMajors) {
collegeMaleTotal += ((Number) item.get("male_total")).intValue();
collegeFemaleTotal += ((Number) item.get("female_total")).intValue();
collegeMaleAssigned += ((Number) item.get("male_assigned")).intValue();
collegeFemaleAssigned += ((Number) item.get("female_assigned")).intValue();
}
XgStudentTreeVo collegeNode = new XgStudentTreeVo();
collegeNode.setId(collegeId.toString());
collegeNode.setLabel(collegeName);
collegeNode.setType("college");
collegeNode.setCollegeId(collegeId);
collegeNode.setCollegeName(collegeName);
collegeNode.setMaleTotal(collegeMaleTotal);
collegeNode.setFemaleTotal(collegeFemaleTotal);
collegeNode.setMaleAssigned(collegeMaleAssigned);
collegeNode.setFemaleAssigned(collegeFemaleAssigned);
collegeNode.setChildren(new ArrayList<>());
Map<String, List<Map<String, Object>>> majorMap = collegeMajors.stream()
.collect(Collectors.groupingBy(item -> {
Object majorIdObj = item.get("major_id");
Long majorId = majorIdObj instanceof Long ? (Long) majorIdObj
: Long.valueOf(majorIdObj.toString());
String majorName = (String) item.get("major_name");
return majorId + "_" + majorName;
}, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry<String, List<Map<String, Object>>> majorEntry : majorMap.entrySet()) {
String majorKey = majorEntry.getKey();
List<Map<String, Object>> majorClasses = majorEntry.getValue();
int majorUnderscore = majorKey.indexOf('_');
Long majorId = Long.valueOf(majorKey.substring(0, majorUnderscore));
String majorName = majorKey.substring(majorUnderscore + 1);
int majorMaleTotal = 0;
int majorFemaleTotal = 0;
int majorMaleAssigned = 0;
int majorFemaleAssigned = 0;
for (Map<String, Object> item : majorClasses) {
majorMaleTotal += ((Number) item.get("male_total")).intValue();
majorFemaleTotal += ((Number) item.get("female_total")).intValue();
majorMaleAssigned += ((Number) item.get("male_assigned")).intValue();
majorFemaleAssigned += ((Number) item.get("female_assigned")).intValue();
}
XgStudentTreeVo majorNode = new XgStudentTreeVo();
majorNode.setId(majorId.toString());
majorNode.setLabel(majorName);
majorNode.setType("major");
majorNode.setMajorId(majorId);
majorNode.setMajorName(majorName);
majorNode.setMaleTotal(majorMaleTotal);
majorNode.setFemaleTotal(majorFemaleTotal);
majorNode.setMaleAssigned(majorMaleAssigned);
majorNode.setFemaleAssigned(majorFemaleAssigned);
majorNode.setChildren(new ArrayList<>());
for (Map<String, Object> classData : majorClasses) {
Object classIdObj = classData.get("class_id");
Long classId = classIdObj instanceof Long ? (Long) classIdObj
: Long.valueOf(classIdObj.toString());
String className = (String) classData.get("class_name");
String grade = (String) classData.get("grade");
XgStudentTreeVo classNode = new XgStudentTreeVo();
classNode.setId(classId.toString());
classNode.setLabel(className);
classNode.setType("class");
classNode.setGrade(grade);
classNode.setClassId(classId);
classNode.setClassName(className);
classNode.setMaleTotal(((Number) classData.get("male_total")).intValue());
classNode.setFemaleTotal(((Number) classData.get("female_total")).intValue());
classNode.setMaleAssigned(((Number) classData.get("male_assigned")).intValue());
classNode.setFemaleAssigned(((Number) classData.get("female_assigned")).intValue());
classNode.setChildren(new ArrayList<>());
majorNode.getChildren().add(classNode);
}
collegeNode.getChildren().add(majorNode);
}
result.add(collegeNode);
}
log.info("成功构建学生信息树,共 {} 个学院", result.size());
return result;
}
/**
* 获取当前用户的二级学院ID
* 通过当前用户的部门id,向上寻找包括他自己,找到dept_category前几位是xy-的就是二级学院
@@ -222,6 +222,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="getCollegeStudentStatsByClass" resultType="java.util.Map">
SELECT
c.grade,
COALESCE(c.enrollment_year, sm.enrollment_year) AS enrollment_year,
sm.college_id,
sm.college_name,
sm.major_id,
@@ -264,10 +265,62 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
AND sm.college_id IS NOT NULL
AND sm.major_id IS NOT NULL
AND sm.class_id IS NOT NULL
GROUP BY c.grade, sm.college_id, sm.college_name, sm.major_id, sm.major_name, sm.class_id, sm.class_name
GROUP BY c.grade, COALESCE(c.enrollment_year, sm.enrollment_year),
sm.college_id, sm.college_name, sm.major_id, sm.major_name, sm.class_id, sm.class_name
ORDER BY c.grade DESC, sm.college_name, sm.major_name, sm.class_name, MAX(sm.create_time) DESC
</select>
<!-- 全校一年级班级统计(宿舍分配到班级:人文学院代理可见) -->
<select id="getFreshmanStudentStatsByClass" resultType="java.util.Map">
SELECT
c.grade,
COALESCE(c.enrollment_year, sm.enrollment_year) AS enrollment_year,
sm.college_id,
sm.college_name,
sm.major_id,
sm.major_name,
sm.class_id,
sm.class_name,
COALESCE(SUM(CASE WHEN sm.gender = '0' AND sm.is_boarder = 1 THEN 1 ELSE 0 END), 0) AS male_total,
COALESCE(SUM(CASE WHEN sm.gender = '1' AND sm.is_boarder = 1 THEN 1 ELSE 0 END), 0) AS female_total,
COALESCE((
SELECT COUNT(DISTINCT dbs.bed_id)
FROM xg_dorm_bed_student dbs
LEFT JOIN xg_dorm_bed db ON dbs.bed_id = db.id
LEFT JOIN xg_dorm_room dr ON db.room_id = dr.id
LEFT JOIN xg_dorm_building dbuild ON dr.building_id = dbuild.id
WHERE dbs.class_id = sm.class_id
AND dbs.deleted = '0'
AND dbs.status = '0'
AND db.deleted = '0'
AND dbuild.building_type = '1'
), 0) AS male_assigned,
COALESCE((
SELECT COUNT(DISTINCT dbs.bed_id)
FROM xg_dorm_bed_student dbs
LEFT JOIN xg_dorm_bed db ON dbs.bed_id = db.id
LEFT JOIN xg_dorm_room dr ON db.room_id = dr.id
LEFT JOIN xg_dorm_building dbuild ON dr.building_id = dbuild.id
WHERE dbs.class_id = sm.class_id
AND dbs.deleted = '0'
AND dbs.status = '0'
AND db.deleted = '0'
AND dbuild.building_type = '2'
), 0) AS female_assigned
FROM xg_student_msg sm
LEFT JOIN xg_class_msg c ON sm.class_id = c.id
WHERE sm.deleted = 0
AND sm.student_status = '1'
AND sm.is_boarder = 1
AND sm.college_id IS NOT NULL
AND sm.major_id IS NOT NULL
AND sm.class_id IS NOT NULL
AND COALESCE(c.enrollment_year, sm.enrollment_year) = #{enrollmentYear}
GROUP BY c.grade, COALESCE(c.enrollment_year, sm.enrollment_year),
sm.college_id, sm.college_name, sm.major_id, sm.major_name, sm.class_id, sm.class_name
ORDER BY sm.college_name, sm.major_name, sm.class_name, MAX(sm.create_time) DESC
</select>
<!-- 查询二级学院下的学院床位分配统计信息(基于学生表统计需求,基于床位分配表统计已分配) -->
<select id="getCollegeBedAssignmentStats" resultType="java.util.Map">
SELECT
@@ -0,0 +1,58 @@
package org.dromara.xg.helper;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 一年级代理学院纯逻辑单测(无 Spring / DB)。
*/
class FreshmanCollegeProxySupportTest {
@Test
void currentCohortYear_beforeSeptember_usesPreviousYear() {
assertEquals(2025, FreshmanCollegeProxySupport.currentCohortYear(LocalDate.of(2026, 8, 31)));
}
@Test
void currentCohortYear_fromSeptember_usesCurrentYear() {
assertEquals(2026, FreshmanCollegeProxySupport.currentCohortYear(LocalDate.of(2026, 9, 1)));
assertEquals(2026, FreshmanCollegeProxySupport.currentCohortYear(LocalDate.of(2026, 10, 12)));
}
@Test
void isFreshman_matchesCohortYearOnly() {
LocalDate today = LocalDate.of(2026, 10, 12);
assertTrue(FreshmanCollegeProxySupport.isFreshmanByEnrollmentYear(2026L, today));
assertFalse(FreshmanCollegeProxySupport.isFreshmanByEnrollmentYear(2025L, today));
assertFalse(FreshmanCollegeProxySupport.isFreshmanByEnrollmentYear(null, today));
}
@Test
void canOperate_freshman_onlyProxyCollege() {
Long proxy = 202512231307170003L;
Long other = 100L;
assertTrue(FreshmanCollegeProxySupport.canOperateClassForDormAllocation(
proxy, other, 2026L, proxy));
assertFalse(FreshmanCollegeProxySupport.canOperateClassForDormAllocation(
other, other, 2026L, proxy));
assertFalse(FreshmanCollegeProxySupport.canOperateClassForDormAllocation(
other, other, 2026L, null));
}
@Test
void canOperate_nonFreshman_onlyOwnCollege() {
Long proxy = 202512231307170003L;
Long own = 100L;
assertTrue(FreshmanCollegeProxySupport.canOperateClassForDormAllocation(
own, own, 2025L, proxy));
assertFalse(FreshmanCollegeProxySupport.canOperateClassForDormAllocation(
proxy, own, 2025L, proxy));
assertFalse(FreshmanCollegeProxySupport.canOperateClassForDormAllocation(
own, null, 2025L, proxy));
}
}
@@ -0,0 +1,31 @@
# 一年级宿舍「分配到班级」代理学院说明
## 业务规则
- 一年级班级的宿舍「分配到班级」由**人文学院**操作,即使班级真实归属其他学院。
- **不修改**班级/学生真实 `college_id`。
- 一年级床位须先在「分配到学院」分给人文学院,人文学院才能看到楼/房/床。
- 其他学院在「分配到班级」中**看不到也不可操作**一年级班级(防双头)。
- 一年级判定:`enrollment_year`,每年 **9 月 1 日**切届(与请假一致)。
- 配置键(复用请假):`xg.studentLeave.freshmanProxyCollegeId`
## 实现要点
| 能力 | 说明 |
|------|------|
| 班级树 | `getCurrentUserStudentTree`:代理院=本院∪全校一年级;其它院=本院−一年级 |
| 写校验 | `batchAllocateBedsToClass` / `batchRecallClassAllocation`:床位须属操作人学院;班级权限按一年级代理规则 |
| 公共逻辑 | `FreshmanCollegeProxyHelper` / `FreshmanCollegeProxySupport` |
## 运营注意
1. 系统参数中确认人文学院 `dept_id` 已配置。
2. 一年级床位在 `xsssfpejxy` 分给人文学院。
3. 切届后:树自动切回本学院;若床位 `college_id` 仍挂人文,需另行移交(本期未自动迁移)。
## 相关代码
- `backend/ruoyi-modules/ruoyi-xg/.../helper/FreshmanCollegeProxyHelper.java`
- `backend/ruoyi-modules/ruoyi-xg/.../service/impl/XgStudentMsgServiceImpl.java`
- `backend/ruoyi-modules/ruoyi-xg/.../service/impl/XgDormBedStudentServiceImpl.java`
- `web/src/views/xg/xgss/ejxyfp/`(前端仍调原 API,无强制改动)
+7
View File
@@ -56,6 +56,13 @@
<!-- 学生树 -->
<el-card shadow="hover" class="tree-card mt-4">
<div class="card-header-title">班级床位需求信息(已分配/总需求)</div>
<el-alert
class="mt-2"
type="info"
:closable="false"
show-icon
title="一年级班级由人文学院统一分配到班级;其它学院仅显示非一年级班级。一年级床位请先在「分配到学院」分给人文学院。"
/>
<el-input
v-model="studentSearchKey"
prefix-icon="Search"