修复住宿:按培训时间窗口校验床位占用冲突

This commit is contained in:
wangzhibiao committed 2026-09-21 16:14:18 +08:00
1 parent 614908d674
commit b3a1fa58d6
3 files changed
+147 -25

No files matched your search

@@ -81,6 +81,34 @@ public interface TrainAccommodationMapper
@Param("bedNo") String bedNo,
@Param("excludeAccommodationId") Long excludeAccommodationId);
/**
* 统计指定宿舍和床位在目标时间窗口内重叠的有效住宿记录数 (Model C: [targetStartTime, targetEndTime) 冲突判断)
*
* @param dormId 宿舍ID
* @param bedNo 床位号
* @param targetStartTime 目标培训开始时间
* @param targetEndTime 目标培训结束时间
* @param excludeAccommodationId 排除的自身住宿ID
* @return 重叠记录数
*/
public int countOverlappingActiveBedReservations(@Param("dormId") String dormId,
@Param("bedNo") String bedNo,
@Param("targetStartTime") Date targetStartTime,
@Param("targetEndTime") Date targetEndTime,
@Param("excludeAccommodationId") Long excludeAccommodationId);
/**
* 统计指定宿舍和床位处于有效状态但无法可靠解析培训时间窗口的住宿记录数 (Fail Closed)
*
* @param dormId 宿舍ID
* @param bedNo 床位号
* @param excludeAccommodationId 排除的自身住宿ID
* @return 无法解析窗口的记录数
*/
public int countUnresolvedActiveBedReservations(@Param("dormId") String dormId,
@Param("bedNo") String bedNo,
@Param("excludeAccommodationId") Long excludeAccommodationId);
/**
* 新增住宿安排
*
@@ -8,10 +8,12 @@ import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainDormitoryInfo;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainClassPlanMapper;
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.service.ITrainAccommodationService;
@@ -38,6 +40,9 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
@Autowired
private TrainEnrollmentMapper trainEnrollmentMapper;
@Autowired
private TrainClassPlanMapper trainClassPlanMapper;
@Autowired(required = false)
private AccessAuthorizationOrchestrator accessAuthorizationOrchestrator;
@@ -115,7 +120,7 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
}
/**
* 新增住宿安排 (带前置依赖、唯一活跃约束与宿舍容量校验)
* 新增住宿安排 (带前置依赖、唯一活跃约束、宿舍排他锁、容量校验与 Model C 床位时间冲突校验)
*
* @param accommodation 住宿安排
* @return 结果
@@ -145,13 +150,13 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
+ ",宿舍: " + activeAcc.getDormId() + "),请先办理退宿或换房");
}
// 3. 验证宿舍存在性与可用状态
// 3. 锁目标 dormitory row: SELECT ... FOR UPDATE (并发串行保护)
String dormId = accommodation.getDormId();
if (dormId == null || dormId.trim().isEmpty())
{
throw new ServiceException("分配宿舍ID (dormId) 不能为空");
}
TrainDormitoryInfo dorm = trainDormitoryInfoMapper.selectTrainDormitoryInfoByDormId(dormId);
TrainDormitoryInfo dorm = trainDormitoryInfoMapper.selectTrainDormitoryInfoForUpdate(dormId);
if (dorm == null)
{
throw new ServiceException("指定宿舍不存在,dormId: " + dormId);
@@ -169,17 +174,10 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
+ ",当前有效人数: " + currentActive + "),无法继续分配");
}
// 5. 验证床位唯一占用约束 (Active Bed Uniqueness Guard: MAJ-06)
if (accommodation.getBedNo() != null && !accommodation.getBedNo().trim().isEmpty())
{
int bedOccupied = trainAccommodationMapper.countActiveByDormIdAndBedNo(dormId, accommodation.getBedNo().trim(), null);
if (bedOccupied > 0)
{
throw new ServiceException("BED_ALREADY_OCCUPIED: 该床位当前已被占用 (宿舍: " + dormId + ",床位: " + accommodation.getBedNo() + ")");
}
}
// 5. Model C: 校验床位在开班计划时间窗口内的占用情况 (Fail Closed)
validateBedOccupancyWindow(dormId, accommodation.getBedNo(), enrollmentId, null);
// 6. 设置默认属性
// 6. 设置默认属性并写入
if (accommodation.getAssignedTime() == null)
{
accommodation.setAssignedTime(DateUtils.getNowDate());
@@ -204,7 +202,7 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
}
/**
* 修改住宿安排
* 修改住宿安排 (带行级排他锁与 Model C 床位时间冲突校验)
*
* @param accommodation 住宿安排
* @return 结果
@@ -225,17 +223,14 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
throw new ServiceException("变更宿舍请使用专属调房换房流程 (transfer),禁止直接修改住宿记录的宿舍ID");
}
// 验证床位唯一占用约束 (更新时排除自身)
String targetDormId = accommodation.getDormId() != null ? accommodation.getDormId() : exist.getDormId();
String targetBedNo = accommodation.getBedNo() != null ? accommodation.getBedNo().trim() : (exist.getBedNo() != null ? exist.getBedNo().trim() : null);
if (targetBedNo != null && !targetBedNo.isEmpty())
{
int bedOccupied = trainAccommodationMapper.countActiveByDormIdAndBedNo(targetDormId, targetBedNo, exist.getAccommodationId());
if (bedOccupied > 0)
{
throw new ServiceException("BED_ALREADY_OCCUPIED: 该床位当前已被占用 (宿舍: " + targetDormId + ",床位: " + targetBedNo + ")");
}
}
String targetBedNo = accommodation.getBedNo() != null ? accommodation.getBedNo() : exist.getBedNo();
// 锁目标宿舍
trainDormitoryInfoMapper.selectTrainDormitoryInfoForUpdate(targetDormId);
// 验证 Model C 床位时间重叠冲突 (排除自身)
validateBedOccupancyWindow(targetDormId, targetBedNo, exist.getEnrollmentId(), exist.getAccommodationId());
accommodation.setUpdateTime(DateUtils.getNowDate());
return trainAccommodationMapper.updateTrainAccommodation(accommodation);
@@ -321,12 +316,30 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
{
throw new ServiceException("原住宿记录已退宿,无法办理调房");
}
if (newDormId == null || newDormId.trim().isEmpty())
{
throw new ServiceException("调房目标宿舍ID不能为空");
}
// 并发保护:按 dorm_id 稳定字典序加锁,避免 A->B 与 B->A 调房死锁
String oldDormId = oldAcc.getDormId();
if (oldDormId != null && !oldDormId.equals(newDormId))
{
String firstDorm = oldDormId.compareTo(newDormId) < 0 ? oldDormId : newDormId;
String secondDorm = oldDormId.compareTo(newDormId) < 0 ? newDormId : oldDormId;
trainDormitoryInfoMapper.selectTrainDormitoryInfoForUpdate(firstDorm);
trainDormitoryInfoMapper.selectTrainDormitoryInfoForUpdate(secondDorm);
}
else
{
trainDormitoryInfoMapper.selectTrainDormitoryInfoForUpdate(newDormId);
}
// 1. 结束旧住宿
trainAccommodationMapper.updateAccommodationStatus(oldAccommodationId,
TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate());
// 2. 建立新住宿
// 2. 建立新住宿 (insertTrainAccommodation 内部已包含目标宿舍锁、容量校验与床位时间冲突校验)
TrainAccommodation newAcc = new TrainAccommodation();
newAcc.setEnrollmentId(oldAcc.getEnrollmentId());
newAcc.setDormId(newDormId);
@@ -389,4 +402,50 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
}
return trainAccommodationMapper.deleteTrainAccommodationByAccommodationId(accommodationId);
}
/**
* Model C: 校验床位在开班计划时间窗口内的占用情况 (Fail Closed)
*
* @param dormId 宿舍ID
* @param bedNo 床位号 (若为空则跳过具体床位冲突检查)
* @param enrollmentId 参训档案ID (用于解析目标开班计划)
* @param excludeAccommodationId 排除的自身住宿记录ID (更新时使用)
*/
private void validateBedOccupancyWindow(String dormId, String bedNo, Long enrollmentId, Long excludeAccommodationId)
{
if (bedNo == null || bedNo.trim().isEmpty())
{
return; // NULL bed_no 保持兼容,跳过具体床位冲突判断,由 room capacity 保护
}
// 1. 获取参训档案与关联开班计划
TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(enrollmentId);
if (enrollment == null || enrollment.getPlanId() == null || enrollment.getPlanId().trim().isEmpty())
{
throw new ServiceException("BED_OCCUPANCY_WINDOW_INVALID: 无法解析参训档案关联的开班计划 (enrollmentId: " + enrollmentId + ")");
}
TrainClassPlan targetPlan = trainClassPlanMapper.selectTrainClassPlanByPlanId(enrollment.getPlanId());
if (targetPlan == null || targetPlan.getStartTime() == null || targetPlan.getEndTime() == null
|| !targetPlan.getEndTime().after(targetPlan.getStartTime()))
{
throw new ServiceException("BED_OCCUPANCY_WINDOW_INVALID: 目标开班计划时间窗口不存在或无效 (planId: " + enrollment.getPlanId() + ")");
}
// 2. 检查同一床位是否存在无法可靠解析时间窗口的已有 active accommodation (Fail Closed)
int unresolvedCount = trainAccommodationMapper.countUnresolvedActiveBedReservations(dormId, bedNo.trim(), excludeAccommodationId);
if (unresolvedCount > 0)
{
throw new ServiceException("BED_OCCUPANCY_WINDOW_UNRESOLVED: 目标床位存在无法解析培训时间窗口的有效预订记录,请人工排查 (宿舍: "
+ dormId + ",床位: " + bedNo + ")");
}
// 3. 检查同一床位在目标时间区间 [start_time, end_time) 内是否存在正长度交集冲突
int overlapCount = trainAccommodationMapper.countOverlappingActiveBedReservations(
dormId, bedNo.trim(), targetPlan.getStartTime(), targetPlan.getEndTime(), excludeAccommodationId);
if (overlapCount > 0)
{
throw new ServiceException("BED_ALREADY_OCCUPIED: 该床位在目标培训周期内已被占用 (宿舍: " + dormId + ",床位: " + bedNo + ")");
}
}
}
@@ -140,6 +140,41 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</if>
</select>
<select id="countOverlappingActiveBedReservations" resultType="int">
SELECT COUNT(1)
FROM train_accommodation a
JOIN train_enrollment e ON a.enrollment_id = e.enrollment_id
JOIN train_class_plan p ON e.plan_id = p.plan_id
WHERE a.dorm_id = #{dormId}
AND a.bed_no = #{bedNo}
AND a.accommodation_status IN (0, 1)
<if test="excludeAccommodationId != null">
AND a.accommodation_id != #{excludeAccommodationId}
</if>
AND p.start_time &lt; #{targetEndTime}
AND p.end_time &gt; #{targetStartTime}
</select>
<select id="countUnresolvedActiveBedReservations" resultType="int">
SELECT COUNT(1)
FROM train_accommodation a
LEFT JOIN train_enrollment e ON a.enrollment_id = e.enrollment_id
LEFT JOIN train_class_plan p ON e.plan_id = p.plan_id
WHERE a.dorm_id = #{dormId}
AND a.bed_no = #{bedNo}
AND a.accommodation_status IN (0, 1)
<if test="excludeAccommodationId != null">
AND a.accommodation_id != #{excludeAccommodationId}
</if>
AND (
e.enrollment_id IS NULL
OR p.plan_id IS NULL
OR p.start_time IS NULL
OR p.end_time IS NULL
OR p.end_time &lt;= p.start_time
)
</select>
<insert id="insertTrainAccommodation" parameterType="TrainAccommodation" useGeneratedKeys="true" keyProperty="accommodationId">
INSERT INTO train_accommodation
<trim prefix="(" suffix=")" suffixOverrides=",">