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 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 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 deleteRoom(@PathVariable Long id) { if (!roomRepo.existsById(id)) { throw new NotFoundException("会议室不存在: " + id); } List 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> 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 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 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 updateMaterial( @PathVariable Long id, @RequestBody MaterialPatchRequest req) { AdminConferenceMaterial m = materialRepo.findById(id) .orElseThrow(() -> new NotFoundException("会务物料记录不存在: " + id)); Set 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 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> 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 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 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 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 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> 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 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 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 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 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> 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 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 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 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 changeStatus( @PathVariable Long id, @RequestBody Map 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 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 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 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; } } }