SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)

恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,549 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.AdminConferenceMaterial;
import com.kaidi.oa.domain.AdminConferenceStaff;
import com.kaidi.oa.domain.AdminEquipSetup;
import com.kaidi.oa.domain.AdminRecurringBooking;
import com.kaidi.oa.domain.Meeting;
import com.kaidi.oa.domain.MeetingRoom;
import com.kaidi.oa.repository.AdminConferenceMaterialRepository;
import com.kaidi.oa.repository.AdminConferenceStaffRepository;
import com.kaidi.oa.repository.AdminEquipSetupRepository;
import com.kaidi.oa.repository.AdminRecurringBookingRepository;
import com.kaidi.oa.repository.MeetingRepository;
import com.kaidi.oa.repository.MeetingRoomRepository;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 行政/办公室·接待与会务服务——补深「会务服务物料管理」PARTIAL 缺口(GAP 8, med)。
*
* 覆盖:
* 1. 会议室 CRUD(新建/编辑/删除,原 MeetingRoomController 仅 GET
* URL: /api/oa/meeting-rooms POST/PATCH/DELETE
* 2. 会务物料准备清单(席卡/茶歇/签到表等)
* URL: /api/oa/admin-conference-materials
* 3. 设备调试记录(投影/音响调试记录及异常闭环)
* URL: /api/oa/admin-equip-setups
* 4. 现场服务人员安排(迎宾/签到/引导/摄影等)
* URL: /api/oa/admin-conference-staff
* 5. 周期会议预订规则管理 + @Scheduled 自动展开(每日向未来 7 天预生成)
* URL: /api/oa/admin-recurring-bookings
*
* 注意:GET /api/oa/meeting-rooms 仍由 MeetingRoomController 处理;
* 本控制器处理同 base path 下的 POST/PATCH/DELETESpring MVC 按 HTTP 方法区分,无冲突)。
*/
@RestController
public class AdminConferenceServiceController {
private final MeetingRoomRepository roomRepo;
private final AdminConferenceMaterialRepository materialRepo;
private final AdminEquipSetupRepository equipRepo;
private final AdminConferenceStaffRepository staffRepo;
private final AdminRecurringBookingRepository recurRepo;
private final MeetingRepository meetingRepo;
public AdminConferenceServiceController(
MeetingRoomRepository roomRepo,
AdminConferenceMaterialRepository materialRepo,
AdminEquipSetupRepository equipRepo,
AdminConferenceStaffRepository staffRepo,
AdminRecurringBookingRepository recurRepo,
MeetingRepository meetingRepo) {
this.roomRepo = roomRepo;
this.materialRepo = materialRepo;
this.equipRepo = equipRepo;
this.staffRepo = staffRepo;
this.recurRepo = recurRepo;
this.meetingRepo = meetingRepo;
}
// ================================================================
// 1. 会议室 CRUD(补 POST/PATCH/DELETEGET 已在 MeetingRoomController
// ================================================================
public record RoomRequest(String name, Integer capacity, String location) {}
@PostMapping("/api/oa/meeting-rooms")
@Transactional
public ApiResp<MeetingRoom> createRoom(@RequestBody RoomRequest req) {
if (req.name() == null || req.name().isBlank()) {
throw new ApiException(400, "会议室名称(name)不能为空");
}
MeetingRoom r = new MeetingRoom();
r.setName(req.name().trim());
r.setCapacity(req.capacity());
r.setLocation(req.location());
return ApiResp.ok(roomRepo.save(r));
}
@PatchMapping("/api/oa/meeting-rooms/{id}")
@Transactional
public ApiResp<MeetingRoom> updateRoom(@PathVariable Long id, @RequestBody RoomRequest req) {
MeetingRoom r = roomRepo.findById(id)
.orElseThrow(() -> new NotFoundException("会议室不存在: " + id));
if (req.name() != null && !req.name().isBlank()) r.setName(req.name().trim());
if (req.capacity() != null) r.setCapacity(req.capacity());
if (req.location() != null) r.setLocation(req.location());
return ApiResp.ok(roomRepo.save(r));
}
@DeleteMapping("/api/oa/meeting-rooms/{id}")
@Transactional
public ApiResp<Void> deleteRoom(@PathVariable Long id) {
if (!roomRepo.existsById(id)) {
throw new NotFoundException("会议室不存在: " + id);
}
List<Meeting> bound = meetingRepo.findByRoomId(id);
long futureMeetings = bound.stream()
.filter(m -> m.getStartTime() != null && m.getStartTime().isAfter(Instant.now()))
.count();
if (futureMeetings > 0) {
throw new ApiException(409, "该会议室有 " + futureMeetings + " 条未来会议占用,请先取消或迁移");
}
roomRepo.deleteById(id);
return ApiResp.ok(null);
}
// ================================================================
// 2. 会务物料准备清单 /api/oa/admin-conference-materials
// ================================================================
@GetMapping("/api/oa/admin-conference-materials")
public ApiResp<List<AdminConferenceMaterial>> listMaterials(
@RequestParam(required = false) Long meetingId,
@RequestParam(required = false) String prepStatus) {
if (meetingId != null) return ApiResp.ok(materialRepo.findByMeetingId(meetingId));
if (prepStatus != null && !prepStatus.isBlank()) return ApiResp.ok(materialRepo.findByPrepStatus(prepStatus));
return ApiResp.ok(materialRepo.findAllByOrderByCreatedAtDesc());
}
@GetMapping("/api/oa/admin-conference-materials/{id}")
public ApiResp<AdminConferenceMaterial> getMaterial(@PathVariable Long id) {
return ApiResp.ok(materialRepo.findById(id)
.orElseThrow(() -> new NotFoundException("会务物料记录不存在: " + id)));
}
public record MaterialRequest(
Long meetingId, String meetingTitle, String materialType,
String materialName, Integer planQty, Integer actualQty,
String unitName, String handler, String readyDate, String remark) {}
@PostMapping("/api/oa/admin-conference-materials")
@Transactional
public ApiResp<AdminConferenceMaterial> createMaterial(@RequestBody MaterialRequest req) {
if (req.meetingTitle() == null || req.meetingTitle().isBlank()) {
throw new ApiException(400, "会议名称(meetingTitle)不能为空");
}
if (req.materialType() == null || req.materialType().isBlank()) {
throw new ApiException(400, "物料类型(materialType)不能为空");
}
AdminConferenceMaterial m = new AdminConferenceMaterial();
m.setMeetingId(req.meetingId());
m.setMeetingTitle(req.meetingTitle().trim());
m.setMaterialType(req.materialType());
m.setMaterialName(req.materialName());
m.setPlanQty(req.planQty() == null ? 1 : req.planQty());
m.setActualQty(req.actualQty());
m.setUnitName(req.unitName() == null ? "" : req.unitName());
m.setHandler(req.handler());
m.setReadyDate(req.readyDate());
m.setRemark(req.remark());
m.setPrepStatus("待准备");
m.setCreatedAt(Instant.now());
m.setUpdatedAt(Instant.now());
return ApiResp.ok(materialRepo.save(m));
}
public record MaterialPatchRequest(String prepStatus, Integer actualQty, String handler, String remark) {}
@PatchMapping("/api/oa/admin-conference-materials/{id}")
@Transactional
public ApiResp<AdminConferenceMaterial> updateMaterial(
@PathVariable Long id, @RequestBody MaterialPatchRequest req) {
AdminConferenceMaterial m = materialRepo.findById(id)
.orElseThrow(() -> new NotFoundException("会务物料记录不存在: " + id));
Set<String> allowed = Set.of("待准备", "准备中", "已就绪", "缺货");
if (req.prepStatus() != null && !allowed.contains(req.prepStatus())) {
throw new ApiException(400, "prepStatus 仅支持: 待准备/准备中/已就绪/缺货");
}
if (req.prepStatus() != null) m.setPrepStatus(req.prepStatus());
if (req.actualQty() != null) m.setActualQty(req.actualQty());
if (req.handler() != null) m.setHandler(req.handler());
if (req.remark() != null) m.setRemark(req.remark());
m.setUpdatedAt(Instant.now());
return ApiResp.ok(materialRepo.save(m));
}
@DeleteMapping("/api/oa/admin-conference-materials/{id}")
@Transactional
public ApiResp<Void> deleteMaterial(@PathVariable Long id) {
if (!materialRepo.existsById(id)) throw new NotFoundException("会务物料记录不存在: " + id);
materialRepo.deleteById(id);
return ApiResp.ok(null);
}
// ================================================================
// 3. 设备调试记录 /api/oa/admin-equip-setups
// ================================================================
@GetMapping("/api/oa/admin-equip-setups")
public ApiResp<List<AdminEquipSetup>> listEquips(
@RequestParam(required = false) Long meetingId,
@RequestParam(required = false) String setupResult) {
if (meetingId != null) return ApiResp.ok(equipRepo.findByMeetingId(meetingId));
if (setupResult != null && !setupResult.isBlank()) return ApiResp.ok(equipRepo.findBySetupResult(setupResult));
return ApiResp.ok(equipRepo.findAllByOrderByCreatedAtDesc());
}
@GetMapping("/api/oa/admin-equip-setups/{id}")
public ApiResp<AdminEquipSetup> getEquip(@PathVariable Long id) {
return ApiResp.ok(equipRepo.findById(id)
.orElseThrow(() -> new NotFoundException("设备调试记录不存在: " + id)));
}
public record EquipRequest(
Long meetingId, String meetingTitle, String equipType,
String equipModel, String technician, String setupDate,
String setupResult, String issueDesc, String resolution, String finalStatus) {}
@PostMapping("/api/oa/admin-equip-setups")
@Transactional
public ApiResp<AdminEquipSetup> createEquip(@RequestBody EquipRequest req) {
if (req.meetingTitle() == null || req.meetingTitle().isBlank()) {
throw new ApiException(400, "会议名称(meetingTitle)不能为空");
}
if (req.equipType() == null || req.equipType().isBlank()) {
throw new ApiException(400, "设备类型(equipType)不能为空");
}
AdminEquipSetup e = new AdminEquipSetup();
e.setMeetingId(req.meetingId());
e.setMeetingTitle(req.meetingTitle().trim());
e.setEquipType(req.equipType());
e.setEquipModel(req.equipModel());
e.setTechnician(req.technician());
e.setSetupDate(req.setupDate() == null ? LocalDate.now().toString() : req.setupDate());
e.setSetupResult(req.setupResult() == null ? "正常" : req.setupResult());
e.setIssueDesc(req.issueDesc());
e.setResolution(req.resolution());
e.setFinalStatus(req.finalStatus() == null
? (req.setupResult() == null ? "正常" : req.setupResult())
: req.finalStatus());
e.setCreatedAt(Instant.now());
e.setUpdatedAt(Instant.now());
return ApiResp.ok(equipRepo.save(e));
}
@PatchMapping("/api/oa/admin-equip-setups/{id}")
@Transactional
public ApiResp<AdminEquipSetup> updateEquip(@PathVariable Long id, @RequestBody EquipRequest req) {
AdminEquipSetup e = equipRepo.findById(id)
.orElseThrow(() -> new NotFoundException("设备调试记录不存在: " + id));
if (req.setupResult() != null) e.setSetupResult(req.setupResult());
if (req.issueDesc() != null) e.setIssueDesc(req.issueDesc());
if (req.resolution() != null) e.setResolution(req.resolution());
if (req.finalStatus() != null) e.setFinalStatus(req.finalStatus());
if (req.technician() != null) e.setTechnician(req.technician());
e.setUpdatedAt(Instant.now());
return ApiResp.ok(equipRepo.save(e));
}
@DeleteMapping("/api/oa/admin-equip-setups/{id}")
@Transactional
public ApiResp<Void> deleteEquip(@PathVariable Long id) {
if (!equipRepo.existsById(id)) throw new NotFoundException("设备调试记录不存在: " + id);
equipRepo.deleteById(id);
return ApiResp.ok(null);
}
// ================================================================
// 4. 现场服务人员安排 /api/oa/admin-conference-staff
// ================================================================
@GetMapping("/api/oa/admin-conference-staff")
public ApiResp<List<AdminConferenceStaff>> listStaff(
@RequestParam(required = false) Long meetingId,
@RequestParam(required = false) String arrivalStatus) {
if (meetingId != null) return ApiResp.ok(staffRepo.findByMeetingId(meetingId));
if (arrivalStatus != null && !arrivalStatus.isBlank()) return ApiResp.ok(staffRepo.findByArrivalStatus(arrivalStatus));
return ApiResp.ok(staffRepo.findAllByOrderByCreatedAtDesc());
}
@GetMapping("/api/oa/admin-conference-staff/{id}")
public ApiResp<AdminConferenceStaff> getStaff(@PathVariable Long id) {
return ApiResp.ok(staffRepo.findById(id)
.orElseThrow(() -> new NotFoundException("服务人员安排不存在: " + id)));
}
public record StaffRequest(
Long meetingId, String meetingTitle, String roleType,
String staffName, String dept, String phone,
String serviceTime, String arrivalStatus, String remark) {}
@PostMapping("/api/oa/admin-conference-staff")
@Transactional
public ApiResp<AdminConferenceStaff> createStaff(@RequestBody StaffRequest req) {
if (req.meetingTitle() == null || req.meetingTitle().isBlank()) {
throw new ApiException(400, "会议名称(meetingTitle)不能为空");
}
if (req.staffName() == null || req.staffName().isBlank()) {
throw new ApiException(400, "服务人员姓名(staffName)不能为空");
}
if (req.roleType() == null || req.roleType().isBlank()) {
throw new ApiException(400, "服务岗位(roleType)不能为空");
}
AdminConferenceStaff s = new AdminConferenceStaff();
s.setMeetingId(req.meetingId());
s.setMeetingTitle(req.meetingTitle().trim());
s.setRoleType(req.roleType());
s.setStaffName(req.staffName().trim());
s.setDept(req.dept());
s.setPhone(req.phone());
s.setServiceTime(req.serviceTime());
s.setArrivalStatus(req.arrivalStatus() == null ? "待到岗" : req.arrivalStatus());
s.setRemark(req.remark());
s.setCreatedAt(Instant.now());
s.setUpdatedAt(Instant.now());
return ApiResp.ok(staffRepo.save(s));
}
@PatchMapping("/api/oa/admin-conference-staff/{id}")
@Transactional
public ApiResp<AdminConferenceStaff> updateStaff(@PathVariable Long id, @RequestBody StaffRequest req) {
AdminConferenceStaff s = staffRepo.findById(id)
.orElseThrow(() -> new NotFoundException("服务人员安排不存在: " + id));
if (req.arrivalStatus() != null) s.setArrivalStatus(req.arrivalStatus());
if (req.staffName() != null && !req.staffName().isBlank()) s.setStaffName(req.staffName());
if (req.roleType() != null && !req.roleType().isBlank()) s.setRoleType(req.roleType());
if (req.serviceTime() != null) s.setServiceTime(req.serviceTime());
if (req.remark() != null) s.setRemark(req.remark());
if (req.phone() != null) s.setPhone(req.phone());
s.setUpdatedAt(Instant.now());
return ApiResp.ok(staffRepo.save(s));
}
@DeleteMapping("/api/oa/admin-conference-staff/{id}")
@Transactional
public ApiResp<Void> deleteStaff(@PathVariable Long id) {
if (!staffRepo.existsById(id)) throw new NotFoundException("服务人员安排不存在: " + id);
staffRepo.deleteById(id);
return ApiResp.ok(null);
}
// ================================================================
// 5. 周期会议预订 /api/oa/admin-recurring-bookings
// ================================================================
@GetMapping("/api/oa/admin-recurring-bookings")
public ApiResp<List<AdminRecurringBooking>> listRecurring(
@RequestParam(required = false) String bookingStatus) {
if (bookingStatus != null && !bookingStatus.isBlank()) {
return ApiResp.ok(recurRepo.findByBookingStatus(bookingStatus));
}
return ApiResp.ok(recurRepo.findAllByOrderByCreatedAtDesc());
}
@GetMapping("/api/oa/admin-recurring-bookings/{id}")
public ApiResp<AdminRecurringBooking> getRecurring(@PathVariable Long id) {
return ApiResp.ok(recurRepo.findById(id)
.orElseThrow(() -> new NotFoundException("周期预订规则不存在: " + id)));
}
public record RecurringRequest(
String ruleName, String subject, Long roomId, String organizer,
String attendees, String recurType, String recurParam,
String startTime, String endTime, String effectFrom, String effectTo) {}
@PostMapping("/api/oa/admin-recurring-bookings")
@Transactional
public ApiResp<AdminRecurringBooking> createRecurring(@RequestBody RecurringRequest req) {
if (req.ruleName() == null || req.ruleName().isBlank()) {
throw new ApiException(400, "规则名称(ruleName)不能为空");
}
if (req.recurType() == null || req.recurType().isBlank()) {
throw new ApiException(400, "周期类型(recurType)不能为空,支持DAILY/WEEKLY/MONTHLY");
}
AdminRecurringBooking rb = new AdminRecurringBooking();
rb.setRuleName(req.ruleName().trim());
rb.setSubject(req.subject() == null ? req.ruleName() : req.subject());
rb.setRoomId(req.roomId());
rb.setOrganizer(req.organizer());
rb.setAttendees(req.attendees());
rb.setRecurType(req.recurType().toUpperCase());
rb.setRecurParam(req.recurParam());
rb.setStartTime(req.startTime() == null ? "09:00" : req.startTime());
rb.setEndTime(req.endTime() == null ? "10:00" : req.endTime());
rb.setEffectFrom(req.effectFrom() == null ? LocalDate.now().toString() : req.effectFrom());
rb.setEffectTo(req.effectTo());
rb.setBookingStatus("生效中");
rb.setLastExpandedDate(LocalDate.now().minusDays(1).toString());
rb.setCreatedAt(Instant.now());
rb.setUpdatedAt(Instant.now());
return ApiResp.ok(recurRepo.save(rb));
}
@PatchMapping("/api/oa/admin-recurring-bookings/{id}")
@Transactional
public ApiResp<AdminRecurringBooking> updateRecurring(@PathVariable Long id, @RequestBody RecurringRequest req) {
AdminRecurringBooking rb = recurRepo.findById(id)
.orElseThrow(() -> new NotFoundException("周期预订规则不存在: " + id));
if (req.ruleName() != null && !req.ruleName().isBlank()) rb.setRuleName(req.ruleName().trim());
if (req.subject() != null) rb.setSubject(req.subject());
if (req.roomId() != null) rb.setRoomId(req.roomId());
if (req.organizer() != null) rb.setOrganizer(req.organizer());
if (req.attendees() != null) rb.setAttendees(req.attendees());
if (req.startTime() != null) rb.setStartTime(req.startTime());
if (req.endTime() != null) rb.setEndTime(req.endTime());
if (req.effectTo() != null) rb.setEffectTo(req.effectTo());
rb.setUpdatedAt(Instant.now());
return ApiResp.ok(recurRepo.save(rb));
}
@PostMapping("/api/oa/admin-recurring-bookings/{id}/status")
@Transactional
public ApiResp<AdminRecurringBooking> changeStatus(
@PathVariable Long id, @RequestBody Map<String, String> body) {
AdminRecurringBooking rb = recurRepo.findById(id)
.orElseThrow(() -> new NotFoundException("周期预订规则不存在: " + id));
String newStatus = body.get("bookingStatus");
if (newStatus == null || !Set.of("生效中", "已暂停", "已结束").contains(newStatus)) {
throw new ApiException(400, "bookingStatus 仅支持: 生效中/已暂停/已结束");
}
rb.setBookingStatus(newStatus);
rb.setUpdatedAt(Instant.now());
return ApiResp.ok(recurRepo.save(rb));
}
@DeleteMapping("/api/oa/admin-recurring-bookings/{id}")
@Transactional
public ApiResp<Void> deleteRecurring(@PathVariable Long id) {
if (!recurRepo.existsById(id)) throw new NotFoundException("周期预订规则不存在: " + id);
recurRepo.deleteById(id);
return ApiResp.ok(null);
}
/**
* @Scheduled 每天凌晨1点:把「生效中」的周期规则向未来7天展开为具体 Meeting 记录。
* 幂等:同一规则同一会议室同一开始时刻已存在则跳过。
*/
@Scheduled(cron = "0 0 1 * * *")
@Transactional
public void expandRecurringBookings() {
LocalDate today = LocalDate.now();
LocalDate horizon = today.plusDays(7);
for (AdminRecurringBooking rb : recurRepo.findByBookingStatus("生效中")) {
LocalDate effectFrom = parseDateSafe(rb.getEffectFrom(), today);
LocalDate effectTo = rb.getEffectTo() == null ? null : parseDateSafe(rb.getEffectTo(), null);
LocalDate lastExpanded = parseDateSafe(rb.getLastExpandedDate(), effectFrom.minusDays(1));
LocalDate startExpand = lastExpanded.plusDays(1);
if (startExpand.isBefore(today)) startExpand = today;
for (LocalDate d = startExpand; !d.isAfter(horizon); d = d.plusDays(1)) {
if (effectTo != null && d.isAfter(effectTo)) break;
if (d.isBefore(effectFrom)) continue;
if (!matchesRecurRule(rb, d)) continue;
LocalTime st = parseTimeSafe(rb.getStartTime(), LocalTime.of(9, 0));
LocalTime et = parseTimeSafe(rb.getEndTime(), LocalTime.of(10, 0));
Instant startInst = d.atTime(st).toInstant(ZoneOffset.ofHours(8));
Instant endInst = d.atTime(et).toInstant(ZoneOffset.ofHours(8));
// 幂等:同房间同开始时刻已存在则跳过
boolean alreadyExists = false;
if (rb.getRoomId() != null) {
List<Meeting> existing = meetingRepo.findByRoomId(rb.getRoomId());
for (Meeting mx : existing) {
if (startInst.equals(mx.getStartTime())) {
alreadyExists = true;
break;
}
}
}
if (alreadyExists) continue;
Meeting m = new Meeting();
m.setSubject(rb.getSubject() + "" + d + "");
m.setStartTime(startInst);
m.setEndTime(endInst);
m.setRoomId(rb.getRoomId());
m.setOrganizer(rb.getOrganizer());
m.setAttendees(rb.getAttendees());
m.setStatus("已预定");
m.setDescription("[周期预订] 规则: " + rb.getRuleName());
meetingRepo.save(m);
}
LocalDate newLast = (effectTo == null || horizon.isBefore(effectTo)) ? horizon : effectTo;
rb.setLastExpandedDate(newLast.toString());
rb.setUpdatedAt(Instant.now());
recurRepo.save(rb);
}
}
/** 手动触发周期展开(前端调试用)。 */
@PostMapping("/api/oa/admin-recurring-bookings/expand-now")
@Transactional
public ApiResp<String> expandNow() {
expandRecurringBookings();
return ApiResp.ok("周期会议已展开完毕");
}
// ================================================================
// 工具方法
// ================================================================
private boolean matchesRecurRule(AdminRecurringBooking rb, LocalDate d) {
String type = rb.getRecurType();
if (type == null) return false;
switch (type) {
case "DAILY": return true;
case "WEEKLY": {
if (rb.getRecurParam() == null || rb.getRecurParam().isBlank()) return false;
int dow = d.getDayOfWeek().getValue();
for (String p : rb.getRecurParam().split(",")) {
try { if (Integer.parseInt(p.trim()) == dow) return true; }
catch (NumberFormatException ignored) {}
}
return false;
}
case "MONTHLY": {
if (rb.getRecurParam() == null || rb.getRecurParam().isBlank()) return false;
try { return d.getDayOfMonth() == Integer.parseInt(rb.getRecurParam().trim()); }
catch (NumberFormatException e) { return false; }
}
default: return false;
}
}
private LocalDate parseDateSafe(String s, LocalDate fallback) {
if (s == null || s.isBlank()) return fallback;
try { return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length()))); }
catch (DateTimeParseException e) { return fallback; }
}
private LocalTime parseTimeSafe(String s, LocalTime fallback) {
if (s == null || s.isBlank()) return fallback;
try { return LocalTime.parse(s.trim()); }
catch (DateTimeParseException e) { return fallback; }
}
}