feat(schedule): 优化编排逻辑,同一项目尽量在同一时间段完成

- 按项目为单位进行分配,而不是按分组
- 计算每个项目的总时长,优先找能容纳整个项目的时间段
- 如果项目总时长不超过时间段容量(480分钟),所有分组都在同一时间段
- 只有当项目总时长超过单个时间段容量时才分散到多个时间段
- 分散时优先选择同一场地的连续时间段
This commit is contained in:
2026-01-15 15:56:28 +08:00
parent 529ac81131
commit 8b0305997e
9 changed files with 116 additions and 96 deletions
Binary file not shown.
@@ -589,117 +589,137 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
log.info("总共初始化了 {} 个场地×时间段组合", slots.size()); log.info("总共初始化了 {} 个场地×时间段组合", slots.size());
// 排序策略: 集体项目(projectType=2)优先,同类型按预计时长降序 // 按项目ID分组,计算每个项目的总时长
groups.sort((a, b) -> { Map<Long, List<ScheduleGroupData>> groupsByProject = groups.stream()
int aType = a.getProjectType() != null ? a.getProjectType() : 1; .collect(Collectors.groupingBy(ScheduleGroupData::getProjectId));
int bType = b.getProjectType() != null ? b.getProjectType() : 1;
if (aType != bType) { // 计算每个项目的总时长
return bType - aType; Map<Long, Integer> projectTotalDuration = new HashMap<>();
} groupsByProject.forEach((projectId, projectGroups) -> {
int aDuration = a.getEstimatedDuration() != null ? a.getEstimatedDuration() : 0; int total = projectGroups.stream().mapToInt(g -> g.getEstimatedDuration() != null ? g.getEstimatedDuration() : 0).sum();
int bDuration = b.getEstimatedDuration() != null ? b.getEstimatedDuration() : 0; projectTotalDuration.put(projectId, total);
return bDuration - aDuration;
}); });
log.info("排序后分组顺序(集体优先): {}", // 排序策略: 集体项目优先,然后按项目总时长降序(大项目优先安排)
groups.stream().map(g -> g.getGroupName() + "(type=" + g.getProjectType() + ")").collect(java.util.stream.Collectors.joining(", "))); List<Long> sortedProjectIds = new ArrayList<>(groupsByProject.keySet());
sortedProjectIds.sort((a, b) -> {
List<ScheduleGroupData> groupsA = groupsByProject.get(a);
List<ScheduleGroupData> groupsB = groupsByProject.get(b);
int typeA = groupsA.get(0).getProjectType() != null ? groupsA.get(0).getProjectType() : 1;
int typeB = groupsB.get(0).getProjectType() != null ? groupsB.get(0).getProjectType() : 1;
if (typeA != typeB) {
return typeB - typeA; // 集体项目优先
}
return projectTotalDuration.get(b) - projectTotalDuration.get(a); // 时长长的优先
});
log.info("项目排序(集体优先,时长降序): {}", sortedProjectIds.stream()
.map(id -> groupsByProject.get(id).get(0).getGroupName().split(" ")[0] + "(" + projectTotalDuration.get(id) + "分钟)")
.collect(Collectors.joining(", ")));
// 记录每个项目已分配的场地ID,用于同一项目优先分配到同一场地
Map<Long, Long> projectVenueMap = new HashMap<>();
int assignedCount = 0; int assignedCount = 0;
Map<Long, Long> projectVenueMap = new HashMap<>(); // 记录项目分配的场地
Map<Long, Integer> projectSlotMap = new HashMap<>(); // 记录项目分配的时间段
for (ScheduleGroupData group : groups) { // 按项目分配(同一项目的所有分组尽量在同一场地同一时间段)
Long projectId = group.getProjectId(); for (Long projectId : sortedProjectIds) {
Long preferredVenueId = projectVenueMap.get(projectId); List<ScheduleGroupData> projectGroups = groupsByProject.get(projectId);
int projectDuration = projectTotalDuration.get(projectId);
String projectName = projectGroups.get(0).getGroupName().split(" ")[0];
SlotInfo bestSlot = null; log.info("开始分配项目 '{}': {}个分组, 总时长{}分钟", projectName, projectGroups.size(), projectDuration);
// 优先策略: 如果该项目已有分配的场地,优先选择同一场地的其他时间段 // 找到能容纳整个项目的最佳槽位(优先选择负载最小的)
if (preferredVenueId != null) { SlotInfo bestSlot = slots.stream()
bestSlot = slots.stream() .filter(s -> s.currentLoad + projectDuration <= s.capacity)
.filter(s -> s.venueId.equals(preferredVenueId)) .min((a, b) -> {
.filter(s -> s.currentLoad + group.getEstimatedDuration() <= s.capacity) // 优先选择负载最小的槽位
.min((a, b) -> Integer.compare(a.currentLoad, b.currentLoad)) return Integer.compare(a.currentLoad, b.currentLoad);
.orElse(null); })
.orElse(null);
if (bestSlot != null) { if (bestSlot != null) {
log.info("项目 '{}' 的分组 '{}' 优先分配到同一场地: {}", // 找到能容纳整个项目的槽位,所有分组都分配到这个槽位
group.getGroupName(), group.getGroupName(), bestSlot.venueName); log.info("项目 '{}' 整体分配到: 场地={}, 时段={}, 剩余容量={}分钟",
} projectName, bestSlot.venueName, bestSlot.timeSlot, bestSlot.capacity - bestSlot.currentLoad);
}
// 如果优先场地没有足够容量,则按负载均衡策略选择 for (ScheduleGroupData group : projectGroups) {
if (bestSlot == null) { group.setAssignedVenueId(bestSlot.venueId);
Map<Long, List<SlotInfo>> slotsByVenue = slots.stream() group.setAssignedVenueName(bestSlot.venueName);
.collect(Collectors.groupingBy(s -> s.venueId)); group.setAssignedDate(bestSlot.date);
group.setAssignedTimeSlot(bestSlot.timeSlot);
group.setAssignedTimeSlotIndex(bestSlot.timeSlotIndex);
group.setAssignedTimePeriod(bestSlot.period);
bestSlot = slots.stream() bestSlot.currentLoad += group.getEstimatedDuration();
.filter(s -> s.currentLoad + group.getEstimatedDuration() <= s.capacity) assignedCount++;
.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);
}
// 如果没有找到容量足够的槽位,选择负载最小的槽位(允许超载) log.info(" 分组 '{}' 分配完成, 时长={}分钟, 槽位负载={}分钟",
if (bestSlot == null) { group.getGroupName(), group.getEstimatedDuration(), bestSlot.currentLoad);
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.setAssignedVenueName(bestSlot.venueName);
group.setAssignedDate(bestSlot.date);
group.setAssignedTimeSlot(bestSlot.timeSlot);
group.setAssignedTimeSlotIndex(bestSlot.timeSlotIndex);
group.setAssignedTimePeriod(bestSlot.period);
bestSlot.currentLoad += group.getEstimatedDuration();
assignedCount++;
// 记录该项目分配的场地
if (!projectVenueMap.containsKey(projectId)) {
projectVenueMap.put(projectId, bestSlot.venueId); projectVenueMap.put(projectId, bestSlot.venueId);
log.info("项目 '{}' 首次分配到场地: {}", group.getGroupName(), bestSlot.venueName); projectSlotMap.put(projectId, bestSlot.timeSlotIndex);
} } else {
// 没有单个槽位能容纳整个项目,需要分散到多个槽位
log.warn("项目 '{}' 总时长{}分钟超过单个时段容量,需要分散分配", projectName, projectDuration);
log.info("分组 '{}' 分配到: 场地ID={}, 场地名={}, 日期={}, 时间段={}, 预计时长={}分钟, 新负载={}分钟 ({}%)", // 优先选择同一场地的连续时间段
group.getGroupName(), bestSlot.venueId, bestSlot.venueName, bestSlot.date, bestSlot.timeSlot, final Long[] preferredVenueId = {null};
group.getEstimatedDuration(), bestSlot.currentLoad,
(int)(bestSlot.currentLoad * 100.0 / bestSlot.capacity)); for (ScheduleGroupData group : projectGroups) {
SlotInfo targetSlot = null;
// 如果已有优先场地,先尝试在同一场地找槽位
if (preferredVenueId[0] != null) {
targetSlot = slots.stream()
.filter(s -> s.venueId.equals(preferredVenueId[0]))
.filter(s -> s.currentLoad + group.getEstimatedDuration() <= s.capacity)
.min((a, b) -> Integer.compare(a.timeSlotIndex, b.timeSlotIndex)) // 优先选择较早的时间段
.orElse(null);
}
// 如果优先场地没有容量,选择任意有容量的槽位
if (targetSlot == null) {
targetSlot = slots.stream()
.filter(s -> s.currentLoad + group.getEstimatedDuration() <= s.capacity)
.min((a, b) -> Integer.compare(a.currentLoad, b.currentLoad))
.orElse(null);
}
// 如果都没有,选择负载最小的(允许超载)
if (targetSlot == null) {
targetSlot = slots.stream()
.min((a, b) -> Integer.compare(a.currentLoad, b.currentLoad))
.orElse(slots.get(0));
log.warn(" 分组 '{}' 需要超载分配", group.getGroupName());
}
group.setAssignedVenueId(targetSlot.venueId);
group.setAssignedVenueName(targetSlot.venueName);
group.setAssignedDate(targetSlot.date);
group.setAssignedTimeSlot(targetSlot.timeSlot);
group.setAssignedTimeSlotIndex(targetSlot.timeSlotIndex);
group.setAssignedTimePeriod(targetSlot.period);
targetSlot.currentLoad += group.getEstimatedDuration();
assignedCount++;
// 记录优先场地
if (preferredVenueId[0] == null) {
preferredVenueId[0] = targetSlot.venueId;
}
log.info(" 分组 '{}' 分配到: 场地={}, 时段={}, 时长={}分钟",
group.getGroupName(), targetSlot.venueName, targetSlot.timeSlot, group.getEstimatedDuration());
}
projectVenueMap.put(projectId, preferredVenueId[0]);
}
} }
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) {