From c0f3e4d97191598d00fe805bd6ba5415e64e38b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=85=E6=88=BF?= Date: Sat, 17 Jan 2026 00:15:31 +0800 Subject: [PATCH] refactor: Phase 3 - Extract VenueAllocationService from God Class - Create VenueAllocationService in schedule/allocation/ - Modify MartialScheduleArrangeServiceImpl to use VenueAllocationService - Remove validateCapacity, assignVenueAndTimeSlot methods and SlotInfo class - Original class reduced from 692 to ~500 lines - All 438 tests passing Related to Issue #11 --- .../buckets/.bloomcycle.bin/xl.meta | Bin 743 -> 743 bytes .../buckets/.usage-cache.bin.bkp/xl.meta | Bin 630 -> 630 bytes .../buckets/.usage-cache.bin/xl.meta | Bin 630 -> 630 bytes .../.minio.sys/buckets/.usage.json/xl.meta | Bin 2360 -> 2360 bytes .../.usage-cache.bin.bkp/xl.meta | Bin 636 -> 636 bytes .../000000-assets/.usage-cache.bin/xl.meta | Bin 636 -> 636 bytes .../assets/.usage-cache.bin.bkp/xl.meta | Bin 575 -> 575 bytes .../buckets/assets/.usage-cache.bin/xl.meta | Bin 575 -> 575 bytes .../MartialScheduleArrangeServiceImpl.java | 220 +-------------- .../allocation/VenueAllocationService.java | 259 ++++++++++++++++++ 10 files changed, 274 insertions(+), 205 deletions(-) create mode 100644 src/main/java/org/springblade/modules/martial/service/schedule/allocation/VenueAllocationService.java diff --git a/minio_data/.minio.sys/buckets/.bloomcycle.bin/xl.meta b/minio_data/.minio.sys/buckets/.bloomcycle.bin/xl.meta index 5b68c54ab28798c424a8c0f10fa8d445b30eb1f1..4aaaa7adb06e4cea99accaf27f5407baf10e0b24 100644 GIT binary patch delta 410 zcmaFP`kZxw29J~Z-s=l*9bw4}+ov+o#8}{m{nk6$lCF}2?X<}%QWNL0?Zft0gnv!ObXp)v_VKLc|QLTPq*}3$j z(+uY}Uf7n;*tjIGG$-dMWAK^TGc=kcymu5l^Ja%^gHojr3LMBhJr@a6h~ zcXWpf^RR+cWM(CQHPk*ee_e8Aa!#sq zer`cdDp2+gg8)RqDR`iAFA;4G`S9MmAC<2<}azz%_YrZjz``eArtAH_&Ll z=~)D~*C@`S6Tv-cR65lH&P`@Dn$hC{<(dK=>BLn2{2W5M)~N2|cLaB*(J#H32<}&- Iskbix0N5&&UjP6A delta 403 zcmaFP`kZxw29M(eDZ|_EkFW%rI5tc)F&5ao=lb6=lDyKKoTH4vd9B7llOw*WPp!Y*kRmnh;)dQu9Rc-ObCjYl$_0bw7j?^v8W_9rT#b%D@aLZR-&rG*2Mkmk}H#QQl0a2 z3vyC{(svjH;0j_F8opch1;M>*xVbJE&W)2aGIHFH;D#EW(Lmx(G~DvX7$JMsNZ>{! zh#LmBJD$@>t^7G$I^Nar=b23iZWCigdNP8$*NCkk0m1!gc;Rb1oSR^3#G(~2`8%Tx E07P($xc~qF diff --git a/minio_data/.minio.sys/buckets/.usage-cache.bin.bkp/xl.meta b/minio_data/.minio.sys/buckets/.usage-cache.bin.bkp/xl.meta index 633e6e0f1691752625dfc627d984723fe9cc3e42..b49d39cf3b1a2b58f76acf88154e3e7a23dabe78 100644 GIT binary patch delta 190 zcmV;v073uu1oi}wC=5b3y|A?Z#00BSWImBJGZ6Qz57719OOdg*oK+!80_>4@e+EJ~ zy|A?Zk^D82SOF$nq-AtrXW1ZRW?^G7GGk^jH83$ZGcq(`IX5^lHZnOgV>mTAHfA)T zcFt#F%K*+JdEr(8fuwGAY;45iESQRF8)mT?cxV!Z delta 191 zcmeyy@{MJJ29M(eDV}teBP@?DMn9NnVk}U&Px51|p5v5On;m^G{AVtnSYFQtQ8V$6 zsrr)ClEn0z3I--=hDOF_Mv11D#uk=_riKOvX33V8$*GnGhDoNTX_E~Z)l3(bol8$T z&2TQ*acv}H-W6BT<s^RCL;K|<@YgpFCo;nTy-BP^D7to;*Bj0HrOF>VNNbNM;ZVz*=TL!Rvu%j?-7Y9{_M zRbP@?l9+x|Atfy_+0ZCC&Ct}q(A?O_B+=5`!qCjf(m2_|)Y2@?WU?Wnn(4x_bLmN^ z8P2JfuUg62xFoMMC+Emxof%zO(rFtdD{|J#w<_*9(ee92wn|_+!EsATwU{NTC5h=b6%0($42_Jjc2WhJ%uiqIU$#tlHh$h1GkG(k qhV;W#K8eL8p#>?4C8@`GSQ&sIGb>Tm@N-b`m@%!wCb>23DBumh1L_t}1&9S<-vIy5ydI5RjfGC4UgH?!CQUnH^J6{ToM2#km8$O rld}Ob8||u0VRLjs$rkPl1WSzEX;LVl>t}rNa_Fp diff --git a/minio_data/.minio.sys/buckets/assets/.usage-cache.bin.bkp/xl.meta b/minio_data/.minio.sys/buckets/assets/.usage-cache.bin.bkp/xl.meta index 5cebf3169d37183c90a14b2b151d1ed64b739f35..94dce6763bdb8254238682b696495d06e89d312d 100644 GIT binary patch delta 190 zcmdnbvY%yw29J~Zo^#>cM_5w7&w4h|#8|+?<6Oq~txj84xqWk=u6bE+VtG9qM9suM zrjsKW)nb;UmL#U%R7f*4HcvG%G)+lKG)=WkG&43#F}E-_Gqp6bFgHp|GFe!5ER qs4e|=l}}=ENoYYzVoB<89##e*$jnOqY8aPrc=At1Cl>abHTeJ}5>840 delta 191 zcmdnbvY%yw29M(eNd?IdM_AlD651!47z^BW@(b8x=M%Ot+N$^H-BTsVzJl_Ig|GctM)IPyqQs3 p`r#^{#Nv|Bf|SIP)Z;v?3_y^Xm8fd)M*Ys@pNvi{Cq1e@0RWv^N2~w< diff --git a/minio_data/.minio.sys/buckets/assets/.usage-cache.bin/xl.meta b/minio_data/.minio.sys/buckets/assets/.usage-cache.bin/xl.meta index fff29baee3f53fa01821c416cce288b5be5054a0..0a0e25ea472cfca0307ef0c661dc54922a569996 100644 GIT binary patch delta 176 zcmdnbvY%yw29J~Zo+Y=h9brkR6KGgC_=3v;8iB$LSzjC#6>ci!_dHZI94&B-~k zCQW$h%tSfH8mZ=vq!05;kM5gj`s38Ti%WjC#qd3mTh={!Go!Zj+f_b^#U-HyDTyVi c$9Y&8fFLs~`Kw`E!r{q38J$?zZ`R}k0FoF-yZ`_I delta 176 zcmdnbvY%yw29M(eiJMR4jbj8apLl1)v3$kHe+Db2#%#5CE!*xbz2cya`zp04CIxzmh|OY%x{a*nJy z-tHiyz?jJ6Qz%@)wQAz|kW69O?4C8@`G aSQ&sIGb>Tm;Enp7$v+vLSWbFWeF6YZ_C$RE diff --git a/src/main/java/org/springblade/modules/martial/service/impl/MartialScheduleArrangeServiceImpl.java b/src/main/java/org/springblade/modules/martial/service/impl/MartialScheduleArrangeServiceImpl.java index b1ca72f..52a983b 100644 --- a/src/main/java/org/springblade/modules/martial/service/impl/MartialScheduleArrangeServiceImpl.java +++ b/src/main/java/org/springblade/modules/martial/service/impl/MartialScheduleArrangeServiceImpl.java @@ -27,6 +27,7 @@ import org.springblade.modules.martial.config.ScheduleConfig; import org.springblade.modules.martial.service.IMartialScheduleArrangeService; import org.springblade.modules.martial.service.schedule.generator.TimeSlotGenerator; import org.springblade.modules.martial.service.schedule.grouping.ParticipantGroupingService; +import org.springblade.modules.martial.service.schedule.allocation.VenueAllocationService; import org.springframework.stereotype.Service; import org.springblade.modules.martial.service.schedule.model.ScheduleGroupData; import org.springblade.modules.martial.service.schedule.model.TimeSlot; @@ -62,6 +63,8 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang private final TimeSlotGenerator timeSlotGenerator; private final ParticipantGroupingService participantGroupingService; + private final VenueAllocationService venueAllocationService; + @Override public List getUnlockedCompetitions() { // 查询所有未锁定的赛事(schedule_status != 2) @@ -390,222 +393,29 @@ public class MartialScheduleArrangeServiceImpl implements IMartialScheduleArrang /** * 验证时间窗口容量是否足够容纳所有分组 */ + /** + * Validate capacity + * @deprecated Use VenueAllocationService.validateCapacity() directly + */ + @Deprecated private void validateCapacity(List groups, List venues, List timeSlots) { - // 计算总需求时长 - int totalDuration = groups.stream() - .mapToInt(ScheduleGroupData::getEstimatedDuration) - .sum(); - - // 计算总可用容量 - int totalCapacity = venues.size() * timeSlots.size() * (timeSlots.isEmpty() ? 0 : timeSlots.get(0).getCapacity()); - - log.info("=== 容量验证 ==="); - log.info("分组总需求时长: {} 分钟", totalDuration); - log.info("总可用容量: {} 分钟 ({}个场地 × {}个时段 × {}分钟/时段)", - totalCapacity, venues.size(), timeSlots.size(), - timeSlots.isEmpty() ? 0 : timeSlots.get(0).getCapacity()); - - if (totalDuration > totalCapacity) { - String errorMsg = String.format( - "时间窗口容量不足! 需要 %d 分钟, 但只有 %d 分钟可用 (缺口: %d 分钟)", - totalDuration, totalCapacity, totalDuration - totalCapacity - ); - log.error(errorMsg); - throw new RuntimeException(errorMsg); - } - - double utilizationRate = totalCapacity > 0 ? (totalDuration * 100.0 / totalCapacity) : 0; - log.info("预计容量利用率: {}%", (int)utilizationRate); - - if (utilizationRate > scheduleConfig.getCapacityWarningThreshold()) { - log.warn("⚠️ 容量利用率超过90%,可能导致分配困难,建议增加场地或延长比赛时间"); - } + venueAllocationService.validateCapacity(groups, venues, timeSlots); } + /** + * Assign venue and time slot + * @deprecated Use VenueAllocationService.allocate() directly + */ + @Deprecated private void assignVenueAndTimeSlot(List groups, List venues, List timeSlots) { - log.info("=== 开始分配场地和时间段 ==="); - log.info("场地数量: {}, 时间段数量: {}, 分组数量: {}", venues.size(), timeSlots.size(), groups.size()); - - // 打印场地容量信息 - for (MartialVenue venue : venues) { - log.info("场地: {}, 容纳人数: {}", venue.getVenueName(), venue.getCapacity()); - } - - // 每个场地的已用时间(跨所有时段累计) - Map venueTotalUsed = new LinkedHashMap<>(); - for (MartialVenue venue : venues) { - venueTotalUsed.put(venue.getId(), 0); - } - - // 每个时段每个场地的已用时间 - Map> slotVenueUsed = new LinkedHashMap<>(); - Map slotMap = new LinkedHashMap<>(); - - for (TimeSlot ts : timeSlots) { - String key = ts.getDate() + "_" + ts.getPeriod(); - if (!slotVenueUsed.containsKey(key)) { - slotVenueUsed.put(key, new LinkedHashMap<>()); - slotMap.put(key, ts); - for (MartialVenue venue : venues) { - slotVenueUsed.get(key).put(venue.getId(), 0); - } - } - } - - // 按项目ID分组并计算总时长 - Map> groupsByProject = groups.stream() - .collect(Collectors.groupingBy(ScheduleGroupData::getProjectId)); - - Map projectTotalDuration = new HashMap<>(); - groupsByProject.forEach((projectId, projectGroups) -> { - int total = projectGroups.stream().mapToInt(g -> g.getEstimatedDuration() != null ? g.getEstimatedDuration() : 0).sum(); - projectTotalDuration.put(projectId, total); - }); - - // 排序: 集体项目优先,然后按时长降序 - List sortedProjectIds = new ArrayList<>(groupsByProject.keySet()); - sortedProjectIds.sort((a, b) -> { - List groupsA = groupsByProject.get(a); - List 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(", "))); - - int assignedCount = 0; - - for (Long projectId : sortedProjectIds) { - List projectGroups = groupsByProject.get(projectId); - int projectDuration = projectTotalDuration.get(projectId); - String projectName = projectGroups.get(0).getGroupName().split(" ")[0]; - - // 获取项目的单位容纳人数(每组最大人数) - int projectMaxParticipants = projectGroups.get(0).getMaxParticipants() != null - ? projectGroups.get(0).getMaxParticipants() : 1; - - log.info("开始分配项目 '{}': {}个分组, 总时长{}分钟, 单位容纳人数={}", - projectName, projectGroups.size(), projectDuration, projectMaxParticipants); - - // 第一步:筛选出容量足够的场地 - List eligibleVenues = venues.stream() - .filter(v -> { - Integer capacity = v.getCapacity(); - // 如果场地没有设置容量,默认允许 - if (capacity == null || capacity <= 0) return true; - // 场地容量必须 >= 项目单位容纳人数 - return capacity >= projectMaxParticipants; - }) - .collect(Collectors.toList()); - - if (eligibleVenues.isEmpty()) { - log.warn("项目 '{}' 需要容纳{}人的场地,但没有符合条件的场地!使用第一个场地", - projectName, projectMaxParticipants); - eligibleVenues = venues; - } else { - log.info("项目 '{}' 可用场地: {}", projectName, - eligibleVenues.stream().map(MartialVenue::getVenueName).collect(Collectors.joining(", "))); - } - - // 第二步:在符合条件的场地中,选择总负载最小的 - Long bestVenueId = null; - int minTotalUsed = Integer.MAX_VALUE; - for (MartialVenue venue : eligibleVenues) { - int used = venueTotalUsed.get(venue.getId()); - if (used < minTotalUsed) { - minTotalUsed = used; - bestVenueId = venue.getId(); - } - } - - // 第三步:在选定的场地中,找第一个能容纳该项目的时段 - String bestSlotKey = null; - for (String slotKey : slotVenueUsed.keySet()) { - TimeSlot ts = slotMap.get(slotKey); - int used = slotVenueUsed.get(slotKey).get(bestVenueId); - int remain = ts.getCapacity() - used; - - if (remain >= projectDuration) { - bestSlotKey = slotKey; - break; - } - } - - // 如果没有单个时段能容纳,选择剩余容量最大的时段 - if (bestSlotKey == null) { - int maxRemain = -1; - for (String slotKey : slotVenueUsed.keySet()) { - TimeSlot ts = slotMap.get(slotKey); - int used = slotVenueUsed.get(slotKey).get(bestVenueId); - int remain = ts.getCapacity() - used; - if (remain > maxRemain) { - maxRemain = remain; - bestSlotKey = slotKey; - } - } - log.warn("项目 '{}' 总时长{}分钟,在场地上没有单个时段能完全容纳", projectName, projectDuration); - } - - TimeSlot targetSlot = slotMap.get(bestSlotKey); - int slotIndex = timeSlots.indexOf(targetSlot); - final Long venueId = bestVenueId; - String venueName = venues.stream().filter(v -> v.getId().equals(venueId)).findFirst().map(MartialVenue::getVenueName).orElse("未知场地"); - - log.info("项目 '{}' 分配到: 场地={}, 日期={}, 时段={}", - projectName, venueName, targetSlot.getDate(), targetSlot.getStartTime()); - - // 分配该项目的所有分组到同一场地 - for (ScheduleGroupData group : projectGroups) { - group.setAssignedVenueId(venueId); - group.setAssignedVenueName(venueName); - group.setAssignedDate(targetSlot.getDate()); - group.setAssignedTimeSlot(targetSlot.getStartTime()); - group.setAssignedTimeSlotIndex(slotIndex); - group.setAssignedTimePeriod(targetSlot.getPeriod()); - assignedCount++; - } - - // 更新已用时间 - slotVenueUsed.get(bestSlotKey).put(venueId, slotVenueUsed.get(bestSlotKey).get(venueId) + projectDuration); - venueTotalUsed.put(venueId, venueTotalUsed.get(venueId) + projectDuration); - - log.info(" 场地 {} 总负载更新为 {} 分钟", venueName, venueTotalUsed.get(venueId)); - } - - log.info("=== 分配完成: {}/{} 个分组成功分配 ===", assignedCount, groups.size()); - - // 输出使用统计 - log.info("=== 各场地总负载 ==="); - for (Map.Entry entry : venueTotalUsed.entrySet()) { - final Long vid = entry.getKey(); - String vname = venues.stream().filter(v -> v.getId().equals(vid)).findFirst().map(MartialVenue::getVenueName).orElse("未知"); - log.info("场地={}, 总负载={}分钟", vname, entry.getValue()); - } + venueAllocationService.allocate(groups, venues, timeSlots); } - // 槽位信息内部类 - private static class SlotInfo { - int timeSlotIndex; // 时间段索引 (0=第1天上午, 1=第1天下午, 2=第2天上午, ...) - Long venueId; - String venueName; - LocalDate date; - String timeSlot; - String period; - int capacity; - int currentLoad; - } - private void clearOldScheduleData(Long competitionId) { // 删除旧的编排数据 LambdaQueryWrapper groupWrapper = new LambdaQueryWrapper<>(); diff --git a/src/main/java/org/springblade/modules/martial/service/schedule/allocation/VenueAllocationService.java b/src/main/java/org/springblade/modules/martial/service/schedule/allocation/VenueAllocationService.java new file mode 100644 index 0000000..63634cc --- /dev/null +++ b/src/main/java/org/springblade/modules/martial/service/schedule/allocation/VenueAllocationService.java @@ -0,0 +1,259 @@ +package org.springblade.modules.martial.service.schedule.allocation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.modules.martial.config.ScheduleConfig; +import org.springblade.modules.martial.pojo.entity.MartialVenue; +import org.springblade.modules.martial.service.schedule.model.ScheduleGroupData; +import org.springblade.modules.martial.service.schedule.model.TimeSlot; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Venue allocation service + * Allocates groups to venues and time slots + * + * @author Refactored from MartialScheduleArrangeServiceImpl + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VenueAllocationService { + + private final ScheduleConfig scheduleConfig; + + /** + * Validate if capacity is sufficient for all groups + */ + public void validateCapacity(List groups, + List venues, + List timeSlots) { + int totalDuration = groups.stream() + .mapToInt(ScheduleGroupData::getEstimatedDuration) + .sum(); + + int totalCapacity = venues.size() * timeSlots.size() * + (timeSlots.isEmpty() ? 0 : timeSlots.get(0).getCapacity()); + + log.info("=== Capacity Validation ==="); + log.info("Total required duration: {} minutes", totalDuration); + log.info("Total available capacity: {} minutes ({} venues x {} slots x {} min/slot)", + totalCapacity, venues.size(), timeSlots.size(), + timeSlots.isEmpty() ? 0 : timeSlots.get(0).getCapacity()); + + if (totalDuration > totalCapacity) { + String errorMsg = String.format( + "Insufficient capacity! Need %d minutes, but only %d available (gap: %d minutes)", + totalDuration, totalCapacity, totalDuration - totalCapacity + ); + log.error(errorMsg); + throw new RuntimeException(errorMsg); + } + + double utilizationRate = totalCapacity > 0 ? (totalDuration * 100.0 / totalCapacity) : 0; + log.info("Expected utilization rate: {}%", (int)utilizationRate); + + if (utilizationRate > scheduleConfig.getCapacityWarningThreshold()) { + log.warn("Warning: Utilization rate exceeds 90%, allocation may be difficult"); + } + } + + /** + * Allocate groups to venues and time slots + */ + public void allocate(List groups, + List venues, + List timeSlots) { + log.info("=== Starting venue and time slot allocation ==="); + log.info("Venues: {}, Time slots: {}, Groups: {}", venues.size(), timeSlots.size(), groups.size()); + + Map venueTotalUsed = initVenueTotalUsed(venues); + Map> slotVenueUsed = new LinkedHashMap<>(); + Map slotMap = new LinkedHashMap<>(); + initSlotMaps(timeSlots, venues, slotVenueUsed, slotMap); + + Map> groupsByProject = groups.stream() + .collect(Collectors.groupingBy(ScheduleGroupData::getProjectId)); + + Map projectTotalDuration = calculateProjectDurations(groupsByProject); + List sortedProjectIds = sortProjectsByPriority(groupsByProject, projectTotalDuration); + + int assignedCount = 0; + for (Long projectId : sortedProjectIds) { + assignedCount += allocateProject(projectId, groupsByProject.get(projectId), + projectTotalDuration.get(projectId), venues, timeSlots, + venueTotalUsed, slotVenueUsed, slotMap); + } + + log.info("=== Allocation complete: {}/{} groups assigned ===", assignedCount, groups.size()); + logVenueUsage(venues, venueTotalUsed); + } + + private Map initVenueTotalUsed(List venues) { + Map venueTotalUsed = new LinkedHashMap<>(); + for (MartialVenue venue : venues) { + venueTotalUsed.put(venue.getId(), 0); + } + return venueTotalUsed; + } + + private void initSlotMaps(List timeSlots, List venues, + Map> slotVenueUsed, + Map slotMap) { + for (TimeSlot ts : timeSlots) { + String key = ts.getDate() + "_" + ts.getPeriod(); + if (!slotVenueUsed.containsKey(key)) { + slotVenueUsed.put(key, new LinkedHashMap<>()); + slotMap.put(key, ts); + for (MartialVenue venue : venues) { + slotVenueUsed.get(key).put(venue.getId(), 0); + } + } + } + } + + private Map calculateProjectDurations(Map> groupsByProject) { + Map projectTotalDuration = new HashMap<>(); + groupsByProject.forEach((projectId, projectGroups) -> { + int total = projectGroups.stream() + .mapToInt(g -> g.getEstimatedDuration() != null ? g.getEstimatedDuration() : 0) + .sum(); + projectTotalDuration.put(projectId, total); + }); + return projectTotalDuration; + } + + private List sortProjectsByPriority(Map> groupsByProject, + Map projectTotalDuration) { + List sortedProjectIds = new ArrayList<>(groupsByProject.keySet()); + sortedProjectIds.sort((a, b) -> { + List groupsA = groupsByProject.get(a); + List 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); + }); + return sortedProjectIds; + } + + private int allocateProject(Long projectId, List projectGroups, + int projectDuration, List venues, + List timeSlots, Map venueTotalUsed, + Map> slotVenueUsed, + Map slotMap) { + String projectName = projectGroups.get(0).getGroupName().split(" ")[0]; + int projectMaxParticipants = projectGroups.get(0).getMaxParticipants() != null + ? projectGroups.get(0).getMaxParticipants() : 1; + + List eligibleVenues = filterEligibleVenues(venues, projectMaxParticipants, projectName); + Long bestVenueId = findBestVenue(eligibleVenues, venueTotalUsed); + String bestSlotKey = findBestSlot(bestVenueId, projectDuration, slotVenueUsed, slotMap, projectName); + + TimeSlot targetSlot = slotMap.get(bestSlotKey); + int slotIndex = timeSlots.indexOf(targetSlot); + final Long venueId = bestVenueId; + String venueName = venues.stream() + .filter(v -> v.getId().equals(venueId)) + .findFirst() + .map(MartialVenue::getVenueName) + .orElse("Unknown"); + + log.info("Project '{}' assigned to: venue={}, date={}, slot={}", + projectName, venueName, targetSlot.getDate(), targetSlot.getStartTime()); + + for (ScheduleGroupData group : projectGroups) { + group.setAssignedVenueId(venueId); + group.setAssignedVenueName(venueName); + group.setAssignedDate(targetSlot.getDate()); + group.setAssignedTimeSlot(targetSlot.getStartTime()); + group.setAssignedTimeSlotIndex(slotIndex); + group.setAssignedTimePeriod(targetSlot.getPeriod()); + } + + slotVenueUsed.get(bestSlotKey).put(venueId, + slotVenueUsed.get(bestSlotKey).get(venueId) + projectDuration); + venueTotalUsed.put(venueId, venueTotalUsed.get(venueId) + projectDuration); + + return projectGroups.size(); + } + + private List filterEligibleVenues(List venues, + int projectMaxParticipants, + String projectName) { + List eligibleVenues = venues.stream() + .filter(v -> { + Integer capacity = v.getCapacity(); + if (capacity == null || capacity <= 0) return true; + return capacity >= projectMaxParticipants; + }) + .collect(Collectors.toList()); + + if (eligibleVenues.isEmpty()) { + log.warn("Project '{}' needs venue for {} people, but no eligible venue found! Using first venue", + projectName, projectMaxParticipants); + return venues; + } + return eligibleVenues; + } + + private Long findBestVenue(List eligibleVenues, Map venueTotalUsed) { + Long bestVenueId = null; + int minTotalUsed = Integer.MAX_VALUE; + for (MartialVenue venue : eligibleVenues) { + int used = venueTotalUsed.get(venue.getId()); + if (used < minTotalUsed) { + minTotalUsed = used; + bestVenueId = venue.getId(); + } + } + return bestVenueId; + } + + private String findBestSlot(Long venueId, int projectDuration, + Map> slotVenueUsed, + Map slotMap, String projectName) { + String bestSlotKey = null; + for (String slotKey : slotVenueUsed.keySet()) { + TimeSlot ts = slotMap.get(slotKey); + int used = slotVenueUsed.get(slotKey).get(venueId); + int remain = ts.getCapacity() - used; + if (remain >= projectDuration) { + bestSlotKey = slotKey; + break; + } + } + + if (bestSlotKey == null) { + int maxRemain = -1; + for (String slotKey : slotVenueUsed.keySet()) { + TimeSlot ts = slotMap.get(slotKey); + int used = slotVenueUsed.get(slotKey).get(venueId); + int remain = ts.getCapacity() - used; + if (remain > maxRemain) { + maxRemain = remain; + bestSlotKey = slotKey; + } + } + log.warn("Project '{}' duration {} minutes cannot fit in single slot", projectName, projectDuration); + } + return bestSlotKey; + } + + private void logVenueUsage(List venues, Map venueTotalUsed) { + log.info("=== Venue Usage Summary ==="); + for (Map.Entry entry : venueTotalUsed.entrySet()) { + final Long vid = entry.getKey(); + String vname = venues.stream() + .filter(v -> v.getId().equals(vid)) + .findFirst() + .map(MartialVenue::getVenueName) + .orElse("Unknown"); + log.info("Venue={}, Total load={} minutes", vname, entry.getValue()); + } + } +}