feat(schedule): 同一项目优先分配到同一场地

- 新增projectVenueMap记录每个项目首次分配的场地
- 同一项目的后续分组优先分配到相同场地
- 只有当优先场地容量不足时才分配到其他场地
- 新增日志输出项目-场地分配情况
This commit is contained in:
2026-01-15 15:56:28 +08:00
parent df7efac819
commit 529ac81131
9 changed files with 77 additions and 48 deletions
Binary file not shown.
@@ -570,11 +570,11 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
// 创建所有槽位(场地 × 时间段组合) // 创建所有槽位(场地 × 时间段组合)
List<SlotInfo> slots = new ArrayList<>(); List<SlotInfo> slots = new ArrayList<>();
int timeSlotIndex = 0; // 时间段索引,对应前端的 timeSlotIndex int timeSlotIndex = 0;
for (TimeSlot timeSlot : timeSlots) { // 先遍历时间段 for (TimeSlot timeSlot : timeSlots) {
for (MartialVenue venue : venues) { // 再遍历场地 for (MartialVenue venue : venues) {
SlotInfo slot = new SlotInfo(); SlotInfo slot = new SlotInfo();
slot.timeSlotIndex = timeSlotIndex; // 同一时间段的所有场地,共享相同的 timeSlotIndex slot.timeSlotIndex = timeSlotIndex;
slot.venueId = venue.getId(); slot.venueId = venue.getId();
slot.venueName = venue.getVenueName(); slot.venueName = venue.getVenueName();
slot.date = timeSlot.getDate(); slot.date = timeSlot.getDate();
@@ -584,20 +584,18 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
slot.currentLoad = 0; slot.currentLoad = 0;
slots.add(slot); slots.add(slot);
} }
timeSlotIndex++; // 每个时间段结束后,索引+1 timeSlotIndex++;
} }
log.info("总共初始化了 {} 个场地×时间段组合", slots.size()); log.info("总共初始化了 {} 个场地×时间段组合", slots.size());
// 排序策略: 集体项目(projectType=2)优先,同类型按预计时长降序 // 排序策略: 集体项目(projectType=2)优先,同类型按预计时长降序
groups.sort((a, b) -> { groups.sort((a, b) -> {
// 1. 集体项目优先于单人项目
int aType = a.getProjectType() != null ? a.getProjectType() : 1; int aType = a.getProjectType() != null ? a.getProjectType() : 1;
int bType = b.getProjectType() != null ? b.getProjectType() : 1; int bType = b.getProjectType() != null ? b.getProjectType() : 1;
if (aType != bType) { if (aType != bType) {
return bType - aType; // 2(集体) > 1(单人) return bType - aType;
} }
// 2. 同类型按预计时长降序(先安排时间长的)
int aDuration = a.getEstimatedDuration() != null ? a.getEstimatedDuration() : 0; int aDuration = a.getEstimatedDuration() != null ? a.getEstimatedDuration() : 0;
int bDuration = b.getEstimatedDuration() != null ? b.getEstimatedDuration() : 0; int bDuration = b.getEstimatedDuration() != null ? b.getEstimatedDuration() : 0;
return bDuration - aDuration; return bDuration - aDuration;
@@ -606,72 +604,102 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
log.info("排序后分组顺序(集体优先): {}", log.info("排序后分组顺序(集体优先): {}",
groups.stream().map(g -> g.getGroupName() + "(type=" + g.getProjectType() + ")").collect(java.util.stream.Collectors.joining(", "))); groups.stream().map(g -> g.getGroupName() + "(type=" + g.getProjectType() + ")").collect(java.util.stream.Collectors.joining(", ")));
// 使用轮询算法进行均匀分配 // 记录每个项目已分配的场地ID,用于同一项目优先分配到同一场地
Map<Long, Long> projectVenueMap = new HashMap<>();
int assignedCount = 0; int assignedCount = 0;
int currentSlotIndex = 0;
for (ScheduleGroupData group : groups) { for (ScheduleGroupData group : groups) {
Long projectId = group.getProjectId();
Long preferredVenueId = projectVenueMap.get(projectId);
SlotInfo bestSlot = null; SlotInfo bestSlot = null;
int attempts = 0;
int maxAttempts = slots.size(); // 优先策略: 如果该项目已有分配的场地,优先选择同一场地的其他时间段
if (preferredVenueId != null) {
// 第一轮:从当前槽位开始轮询,寻找第一个容量足够的槽位
int startIndex = currentSlotIndex;
while (attempts < maxAttempts) {
SlotInfo slot = slots.get(currentSlotIndex);
// 检查容量是否足够
if (slot.currentLoad + group.getEstimatedDuration() <= slot.capacity) {
bestSlot = slot;
break;
}
// 尝试下一个槽位
currentSlotIndex = (currentSlotIndex + 1) % slots.size();
attempts++;
}
// 第二轮:如果没有找到容量足够的槽位,选择负载最小的槽位(允许超载)
if (bestSlot == null) {
log.warn("分组 '{}' 需要{}分钟,超过单个槽位容量{}分钟,将选择负载最小的槽位(允许超载)",
group.getGroupName(), group.getEstimatedDuration(), slots.get(0).capacity);
bestSlot = slots.stream() bestSlot = slots.stream()
.filter(s -> s.venueId.equals(preferredVenueId))
.filter(s -> s.currentLoad + group.getEstimatedDuration() <= s.capacity)
.min((a, b) -> Integer.compare(a.currentLoad, b.currentLoad)) .min((a, b) -> Integer.compare(a.currentLoad, b.currentLoad))
.orElse(slots.get(0)); .orElse(null);
// 设置currentSlotIndex到bestSlot的位置 if (bestSlot != null) {
for (int i = 0; i < slots.size(); i++) { log.info("项目 '{}' 的分组 '{}' 优先分配到同一场地: {}",
if (slots.get(i) == bestSlot) { group.getGroupName(), group.getGroupName(), bestSlot.venueName);
currentSlotIndex = i; }
break; }
}
// 如果优先场地没有足够容量,则按负载均衡策略选择
if (bestSlot == null) {
Map<Long, List<SlotInfo>> slotsByVenue = slots.stream()
.collect(Collectors.groupingBy(s -> s.venueId));
bestSlot = slots.stream()
.filter(s -> s.currentLoad + group.getEstimatedDuration() <= s.capacity)
.min((a, b) -> {
int loadA = slotsByVenue.get(a.venueId).stream().mapToInt(s -> s.currentLoad).sum();
int loadB = slotsByVenue.get(b.venueId).stream().mapToInt(s -> s.currentLoad).sum();
if (loadA != loadB) {
return Integer.compare(loadA, loadB);
}
return Integer.compare(a.currentLoad, b.currentLoad);
})
.orElse(null);
}
// 如果没有找到容量足够的槽位,选择负载最小的槽位(允许超载)
if (bestSlot == null) {
log.warn("分组 '{}' 需要{}分钟,超过单个槽位容量,将选择负载最小的槽位(允许超载)",
group.getGroupName(), group.getEstimatedDuration());
if (preferredVenueId != null) {
bestSlot = slots.stream()
.filter(s -> s.venueId.equals(preferredVenueId))
.min((a, b) -> Integer.compare(a.currentLoad, b.currentLoad))
.orElse(null);
}
if (bestSlot == null) {
bestSlot = slots.stream()
.min((a, b) -> Integer.compare(a.currentLoad, b.currentLoad))
.orElse(slots.get(0));
} }
} }
// 分配到选定的槽位
group.setAssignedVenueId(bestSlot.venueId); group.setAssignedVenueId(bestSlot.venueId);
group.setAssignedVenueName(bestSlot.venueName); group.setAssignedVenueName(bestSlot.venueName);
group.setAssignedDate(bestSlot.date); group.setAssignedDate(bestSlot.date);
group.setAssignedTimeSlot(bestSlot.timeSlot); group.setAssignedTimeSlot(bestSlot.timeSlot);
group.setAssignedTimeSlotIndex(bestSlot.timeSlotIndex); // 保存时间段索引 group.setAssignedTimeSlotIndex(bestSlot.timeSlotIndex);
group.setAssignedTimePeriod(bestSlot.period); group.setAssignedTimePeriod(bestSlot.period);
// 更新槽位负载
bestSlot.currentLoad += group.getEstimatedDuration(); bestSlot.currentLoad += group.getEstimatedDuration();
assignedCount++; assignedCount++;
// 记录该项目分配的场地
if (!projectVenueMap.containsKey(projectId)) {
projectVenueMap.put(projectId, bestSlot.venueId);
log.info("项目 '{}' 首次分配到场地: {}", group.getGroupName(), bestSlot.venueName);
}
log.info("分组 '{}' 分配到: 场地ID={}, 场地名={}, 日期={}, 时间段={}, 预计时长={}分钟, 新负载={}分钟 ({}%)", log.info("分组 '{}' 分配到: 场地ID={}, 场地名={}, 日期={}, 时间段={}, 预计时长={}分钟, 新负载={}分钟 ({}%)",
group.getGroupName(), bestSlot.venueId, bestSlot.venueName, bestSlot.date, bestSlot.timeSlot, group.getGroupName(), bestSlot.venueId, bestSlot.venueName, bestSlot.date, bestSlot.timeSlot,
group.getEstimatedDuration(), bestSlot.currentLoad, group.getEstimatedDuration(), bestSlot.currentLoad,
(int)(bestSlot.currentLoad * 100.0 / bestSlot.capacity)); (int)(bestSlot.currentLoad * 100.0 / bestSlot.capacity));
// 移动到下一个槽位(轮询)
currentSlotIndex = (currentSlotIndex + 1) % slots.size();
} }
log.info("=== 分配完成: {}/{} 个分组成功分配 ===", assignedCount, groups.size()); log.info("=== 分配完成: {}/{} 个分组成功分配 ===", assignedCount, groups.size());
// 输出每个项目的场地分配情况
log.info("=== 项目-场地分配情况 ===");
Map<Long, String> venueNameMap = venues.stream().collect(Collectors.toMap(MartialVenue::getId, MartialVenue::getVenueName));
projectVenueMap.forEach((projectId, venueId) -> {
String projectName = groups.stream()
.filter(g -> g.getProjectId().equals(projectId))
.map(ScheduleGroupData::getGroupName)
.findFirst().orElse("未知项目");
log.info("项目: {} -> 场地: {}", projectName, venueNameMap.get(venueId));
});
// 输出每个槽位的负载情况 // 输出每个槽位的负载情况
log.info("=== 各槽位负载统计 ==="); log.info("=== 各槽位负载统计 ===");
for (SlotInfo slot : slots) { for (SlotInfo slot : slots) {
@@ -683,6 +711,7 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
} }
} }
// 槽位信息内部类 // 槽位信息内部类
private static class SlotInfo { private static class SlotInfo {
int timeSlotIndex; // 时间段索引 (0=第1天上午, 1=第1天下午, 2=第2天上午, ...) int timeSlotIndex; // 时间段索引 (0=第1天上午, 1=第1天下午, 2=第2天上午, ...)