feat(schedule): 优化编排时间分配逻辑

- 添加晚上时段(19:00-22:00, 180分钟)
- 调整时段容量: 上午240分钟, 下午240分钟, 晚上180分钟
- 按实际时长累加分配,同一时段可安排多个项目
- 同一项目的所有分组优先安排在同一场地同一时段
This commit is contained in:
2026-01-15 15:56:28 +08:00
parent 8b0305997e
commit aee06393d7
9 changed files with 100 additions and 118 deletions
Binary file not shown.
@@ -325,24 +325,36 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
TimeSlot morning = new TimeSlot();
morning.setDate(currentDate);
morning.setPeriod("morning");
morning.setStartTime("08:30");
morning.setCapacity(480); // 上午480分钟(8小时,允许并发/灵活安排)
morning.setStartTime("08:00");
morning.setCapacity(240); // 4小时 = 240分钟
timeSlots.add(morning);
// 下午时段 (13:00-18:00, 共300分钟)
// 下午时段 (14:00-18:00, 共240分钟)
TimeSlot afternoon = new TimeSlot();
afternoon.setDate(currentDate);
afternoon.setPeriod("afternoon");
afternoon.setStartTime("13:30");
afternoon.setCapacity(480); // 下午480分钟(8小时,允许并发/灵活安排)
afternoon.setStartTime("14:00");
afternoon.setCapacity(240); // 4小时 = 240分钟
timeSlots.add(afternoon);
// 晚上时段 (19:00-22:00, 共180分钟)
TimeSlot evening = new TimeSlot();
evening.setDate(currentDate);
evening.setPeriod("evening");
evening.setStartTime("19:00");
evening.setCapacity(180); // 3小时 = 180分钟
timeSlots.add(evening);
currentDate = currentDate.plusDays(1);
}
log.info("生成时间段: {}天, 每天3个时段(上午/下午/晚上), 共{}个时段",
java.time.temporal.ChronoUnit.DAYS.between(startTime.toLocalDate(), endDate) + 1, timeSlots.size());
return timeSlots;
}
private List<ScheduleGroupData> autoGroupParticipants(List<MartialAthlete> athletes) {
List<ScheduleGroupData> groups = new ArrayList<>();
int displayOrder = 1;
@@ -568,28 +580,22 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
log.info("=== 开始分配场地和时间段 ===");
log.info("场地数量: {}, 时间段数量: {}, 分组数量: {}", venues.size(), timeSlots.size(), groups.size());
// 创建所有槽位(场地 × 时间段组合)
List<SlotInfo> slots = new ArrayList<>();
// 为每个场地创建时间轴(记录当前已用时间)
// key = venueId + "_" + timeSlotIndex, value = 当前已用分钟数
Map<String, Integer> venueTimeUsed = new HashMap<>();
Map<String, TimeSlot> venueTimeSlotMap = new HashMap<>();
int timeSlotIndex = 0;
for (TimeSlot timeSlot : timeSlots) {
for (MartialVenue venue : venues) {
SlotInfo slot = new SlotInfo();
slot.timeSlotIndex = timeSlotIndex;
slot.venueId = venue.getId();
slot.venueName = venue.getVenueName();
slot.date = timeSlot.getDate();
slot.timeSlot = timeSlot.getStartTime();
slot.period = timeSlot.getPeriod();
slot.capacity = timeSlot.getCapacity();
slot.currentLoad = 0;
slots.add(slot);
String key = venue.getId() + "_" + timeSlotIndex;
venueTimeUsed.put(key, 0);
venueTimeSlotMap.put(key, timeSlot);
}
timeSlotIndex++;
}
log.info("总共初始化了 {} 个场地×时间段组合", slots.size());
// 按项目ID分组,计算每个项目的总时长
// 按项目ID分组
Map<Long, List<ScheduleGroupData>> groupsByProject = groups.stream()
.collect(Collectors.groupingBy(ScheduleGroupData::getProjectId));
@@ -600,7 +606,7 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
projectTotalDuration.put(projectId, total);
});
// 排序策略: 集体项目优先,然后按项目总时长降序(大项目优先安排)
// 排序: 集体项目优先然后按时长降序
List<Long> sortedProjectIds = new ArrayList<>(groupsByProject.keySet());
sortedProjectIds.sort((a, b) -> {
List<ScheduleGroupData> groupsA = groupsByProject.get(a);
@@ -608,20 +614,18 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
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 typeB - typeA;
}
return projectTotalDuration.get(b) - projectTotalDuration.get(a); // 时长长的优先
return projectTotalDuration.get(b) - projectTotalDuration.get(a);
});
log.info("项目排序(集体优先,时长降序): {}", sortedProjectIds.stream()
log.info("项目排序: {}", sortedProjectIds.stream()
.map(id -> groupsByProject.get(id).get(0).getGroupName().split(" ")[0] + "(" + projectTotalDuration.get(id) + "分钟)")
.collect(Collectors.joining(", ")));
int assignedCount = 0;
Map<Long, Long> projectVenueMap = new HashMap<>(); // 记录项目分配的场地
Map<Long, Integer> projectSlotMap = new HashMap<>(); // 记录项目分配的时间段
Map<Long, String> projectVenueSlotMap = new HashMap<>(); // 记录项目分配的场地+时段
// 按项目分配(同一项目的所有分组尽量在同一场地同一时间段)
for (Long projectId : sortedProjectIds) {
List<ScheduleGroupData> projectGroups = groupsByProject.get(projectId);
int projectDuration = projectTotalDuration.get(projectId);
@@ -629,104 +633,82 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang
log.info("开始分配项目 '{}': {}个分组, 总时长{}分钟", projectName, projectGroups.size(), projectDuration);
// 找到能容纳整个项目的最佳槽位(优先选择负载最小的)
SlotInfo bestSlot = slots.stream()
.filter(s -> s.currentLoad + projectDuration <= s.capacity)
.min((a, b) -> {
// 优先选择负载最小的槽位
return Integer.compare(a.currentLoad, b.currentLoad);
})
.orElse(null);
if (bestSlot != null) {
// 找到能容纳整个项目的槽位,所有分组都分配到这个槽位
log.info("项目 '{}' 整体分配到: 场地={}, 时段={}, 剩余容量={}分钟",
projectName, bestSlot.venueName, bestSlot.timeSlot, bestSlot.capacity - bestSlot.currentLoad);
// 找到能容纳项目的最佳场地+时段(优先选择已用时间最少的)
String bestKey = null;
int minUsed = Integer.MAX_VALUE;
for (Map.Entry<String, Integer> entry : venueTimeUsed.entrySet()) {
String key = entry.getKey();
int used = entry.getValue();
TimeSlot ts = venueTimeSlotMap.get(key);
for (ScheduleGroupData group : projectGroups) {
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++;
log.info(" 分组 '{}' 分配完成, 时长={}分钟, 槽位负载={}分钟",
group.getGroupName(), group.getEstimatedDuration(), bestSlot.currentLoad);
// 检查剩余容量是否足够
if (used + projectDuration <= ts.getCapacity()) {
if (used < minUsed) {
minUsed = used;
bestKey = key;
}
}
projectVenueMap.put(projectId, bestSlot.venueId);
projectSlotMap.put(projectId, bestSlot.timeSlotIndex);
} else {
// 没有单个槽位能容纳整个项目,需要分散到多个槽位
log.warn("项目 '{}' 总时长{}分钟超过单个时段容量,需要分散分配", projectName, projectDuration);
// 优先选择同一场地的连续时间段
final Long[] preferredVenueId = {null};
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]);
}
// 如果没有单个时段能容纳,选择剩余容量最大的
if (bestKey == null) {
int maxRemain = -1;
for (Map.Entry<String, Integer> entry : venueTimeUsed.entrySet()) {
String key = entry.getKey();
int used = entry.getValue();
TimeSlot ts = venueTimeSlotMap.get(key);
int remain = ts.getCapacity() - used;
if (remain > maxRemain) {
maxRemain = remain;
bestKey = key;
}
}
log.warn("项目 '{}' 总时长{}分钟可能需要跨时段", projectName, projectDuration);
}
// 解析场地ID和时段索引
String[] parts = bestKey.split("_");
Long venueId = Long.parseLong(parts[0]);
int slotIndex = Integer.parseInt(parts[1]);
TimeSlot timeSlot = venueTimeSlotMap.get(bestKey);
String venueName = venues.stream().filter(v -> v.getId().equals(venueId)).findFirst().map(MartialVenue::getVenueName).orElse("未知场地");
log.info("项目 '{}' 分配到: 场地={}, 时段={}, 当前已用={}分钟", projectName, venueName, timeSlot.getStartTime(), venueTimeUsed.get(bestKey));
// 分配该项目的所有分组到同一场地+时段
for (ScheduleGroupData group : projectGroups) {
group.setAssignedVenueId(venueId);
group.setAssignedVenueName(venueName);
group.setAssignedDate(timeSlot.getDate());
group.setAssignedTimeSlot(timeSlot.getStartTime());
group.setAssignedTimeSlotIndex(slotIndex);
group.setAssignedTimePeriod(timeSlot.getPeriod());
assignedCount++;
log.info(" 分组 '{}' 时长={}分钟", group.getGroupName(), group.getEstimatedDuration());
}
// 更新已用时间
venueTimeUsed.put(bestKey, venueTimeUsed.get(bestKey) + projectDuration);
projectVenueSlotMap.put(projectId, bestKey);
log.info(" 场地 {} 时段 {} 已用时间更新为 {} 分钟", venueName, timeSlot.getStartTime(), venueTimeUsed.get(bestKey));
}
log.info("=== 分配完成: {}/{} 个分组成功分配 ===", assignedCount, groups.size());
// 输出每个槽位的负载情况
log.info("=== 各槽位负载统计 ===");
for (SlotInfo slot : slots) {
if (slot.currentLoad > 0) {
log.info("场地={}, 日期={}, 时段={}, 负载={}/{}分钟 ({}%)",
slot.venueName, slot.date, slot.timeSlot, slot.currentLoad, slot.capacity,
(int)(slot.currentLoad * 100.0 / slot.capacity));
// 输出各场地时段的使用情况
log.info("=== 各场地时段使用统计 ===");
for (Map.Entry<String, Integer> entry : venueTimeUsed.entrySet()) {
if (entry.getValue() > 0) {
String[] parts = entry.getKey().split("_");
Long venueId = Long.parseLong(parts[0]);
String venueName = venues.stream().filter(v -> v.getId().equals(venueId)).findFirst().map(MartialVenue::getVenueName).orElse("未知");
TimeSlot ts = venueTimeSlotMap.get(entry.getKey());
log.info("场地={}, 日期={}, 时段={}, 已用={}/{}分钟 ({}%)",
venueName, ts.getDate(), ts.getStartTime(), entry.getValue(), ts.getCapacity(),
(int)(entry.getValue() * 100.0 / ts.getCapacity()));
}
}
}