恢复点(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>
550 lines
26 KiB
Java
550 lines
26 KiB
Java
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/DELETE(Spring 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/DELETE,GET 已在 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; }
|
||
}
|
||
}
|