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.MeetingActionItem; import com.kaidi.oa.domain.MeetingMinute; import com.kaidi.oa.repository.MeetingActionItemRepository; import com.kaidi.oa.repository.MeetingMinuteRepository; 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.util.ArrayList; import java.util.List; /** * 会议纪要待办与跟踪(知识产权部,需求功能13 — 补深 PARTIAL)。 * * 补深审计缺口:会议纪要无自动生成待办+跟踪+责任人。 * - generate-from-minute:读取 MeetingMinute.decisions(决议,按行/分号拆分),自动生成待办事项, * 与责任人/截止日关联,状态置「待办」; * - 手工增删改 + 状态流转(待办→进行中→已完成/已取消)跟踪完成; * - overdue 端点列出逾期未完成待办,board 端点给出完成率看板。 * * 新控制器默认受 AuthInterceptor 保护。 */ @RestController @RequestMapping("/api/oa/meeting-action-items") public class MeetingActionItemController { private final MeetingActionItemRepository repo; private final MeetingMinuteRepository minuteRepo; public MeetingActionItemController(MeetingActionItemRepository repo, MeetingMinuteRepository minuteRepo) { this.repo = repo; this.minuteRepo = minuteRepo; } @GetMapping public ApiResp> list(@RequestParam(required = false) Long minuteId, @RequestParam(required = false) String assignee, @RequestParam(required = false) String status) { if (minuteId != null) { return ApiResp.ok(repo.findByMinuteIdOrderByIdAsc(minuteId)); } if (assignee != null && !assignee.isBlank()) { return ApiResp.ok(repo.findByAssigneeOrderByIdDesc(assignee)); } if (status != null && !status.isBlank()) { return ApiResp.ok(repo.findByStatus(status)); } return ApiResp.ok(repo.findAll()); } public record ItemRequest(Long minuteId, String content, String assignee, String dueDate) { } @PostMapping @Transactional public ApiResp create(@RequestBody ItemRequest req) { if (req.content() == null || req.content().isBlank()) { throw new ApiException(400, "待办内容不能为空"); } MeetingActionItem it = new MeetingActionItem(); it.setMinuteId(req.minuteId()); if (req.minuteId() != null) { minuteRepo.findById(req.minuteId()).ifPresent(m -> it.setMinuteTitle(m.getTitle())); } it.setContent(req.content()); it.setAssignee(req.assignee()); it.setDueDate(req.dueDate()); it.setStatus("待办"); it.setCreatedAt(Instant.now()); it.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(it)); } public record GenerateRequest(Long minuteId, String defaultAssignee, String defaultDueDate) { } /** * 从会议纪要自动生成待办:读取纪要 decisions(按换行/分号/;拆分为多条),逐条建为「待办」, * 责任人/截止日缺省用入参兜底。已存在的同纪要待办不重复生成(按内容去重)。 */ @PostMapping("/generate-from-minute") @Transactional public ApiResp> generate(@RequestBody GenerateRequest req) { if (req.minuteId() == null) { throw new ApiException(400, "缺少会议纪要 minuteId"); } MeetingMinute m = minuteRepo.findById(req.minuteId()) .orElseThrow(() -> new NotFoundException("会议纪要不存在:" + req.minuteId())); String decisions = m.getDecisions(); if (decisions == null || decisions.isBlank()) { throw new ApiException(409, "该纪要无决议内容(decisions 为空),无法生成待办"); } List existing = repo.findByMinuteIdOrderByIdAsc(req.minuteId()); List created = new ArrayList<>(); Instant now = Instant.now(); for (String raw : decisions.split("[\\r\\n;;]+")) { String line = raw.trim(); if (line.isEmpty()) { continue; } boolean dup = existing.stream().anyMatch(x -> line.equals(x.getContent())); if (dup) { continue; } MeetingActionItem it = new MeetingActionItem(); it.setMinuteId(m.getId()); it.setMinuteTitle(m.getTitle()); it.setContent(line); it.setAssignee(req.defaultAssignee()); it.setDueDate(req.defaultDueDate()); it.setStatus("待办"); it.setCreatedAt(now); it.setUpdatedAt(now); created.add(it); } if (created.isEmpty()) { throw new ApiException(409, "未解析出新的待办(决议已全部生成)"); } return ApiResp.ok(repo.saveAll(created)); } @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody ItemRequest req) { MeetingActionItem it = find(id); if (req.content() != null && !req.content().isBlank()) it.setContent(req.content()); if (req.assignee() != null) it.setAssignee(req.assignee()); if (req.dueDate() != null) it.setDueDate(req.dueDate()); it.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(it)); } public record StatusRequest(String status) { } /** 状态流转:待办 → 进行中 → 已完成;任意可取消。完成时回填完成日。 */ @PostMapping("/{id}/status") @Transactional public ApiResp setStatus(@PathVariable Long id, @RequestBody StatusRequest req) { MeetingActionItem it = find(id); String to = req.status() == null ? "" : req.status().trim(); if (!List.of("待办", "进行中", "已完成", "已取消").contains(to)) { throw new ApiException(400, "未知状态:" + to); } it.setStatus(to); it.setCompletedDate("已完成".equals(to) ? LocalDate.now().toString() : null); it.setUpdatedAt(Instant.now()); return ApiResp.ok(repo.save(it)); } @DeleteMapping("/{id}") @Transactional public ApiResp delete(@PathVariable Long id) { find(id); repo.deleteById(id); return ApiResp.ok(null); } // ---------- 逾期 + 看板 ---------- public record OverdueItem(Long id, String content, String assignee, String dueDate, long daysOverdue, String minuteTitle) { } @GetMapping("/overdue") public ApiResp> overdue() { LocalDate today = LocalDate.now(); List out = new ArrayList<>(); for (MeetingActionItem it : repo.findAll()) { if ("已完成".equals(it.getStatus()) || "已取消".equals(it.getStatus())) { continue; } LocalDate due = parseDateOrNull(it.getDueDate()); if (due == null || !due.isBefore(today)) { continue; } long over = java.time.temporal.ChronoUnit.DAYS.between(due, today); out.add(new OverdueItem(it.getId(), it.getContent(), it.getAssignee(), it.getDueDate(), over, it.getMinuteTitle())); } out.sort((a, b) -> Long.compare(b.daysOverdue(), a.daysOverdue())); return ApiResp.ok(out); } public record Board(int total, int pending, int inProgress, int done, int cancelled, double completionRate) { } @GetMapping("/board") public ApiResp board() { List all = repo.findAll(); int pending = 0; int inProgress = 0; int done = 0; int cancelled = 0; for (MeetingActionItem it : all) { switch (it.getStatus() == null ? "" : it.getStatus()) { case "待办" -> pending++; case "进行中" -> inProgress++; case "已完成" -> done++; case "已取消" -> cancelled++; default -> { // unknown status ignored from buckets } } } int effective = all.size() - cancelled; double rate = effective <= 0 ? 0d : Math.round((done * 10000d) / effective) / 100d; return ApiResp.ok(new Board(all.size(), pending, inProgress, done, cancelled, rate)); } private MeetingActionItem find(Long id) { return repo.findById(id) .orElseThrow(() -> new NotFoundException("待办事项不存在:" + id)); } private static LocalDate parseDateOrNull(String s) { if (s == null || s.isBlank()) { return null; } try { String t = s.trim(); return LocalDate.parse(t.substring(0, Math.min(10, t.length()))); } catch (RuntimeException e) { return null; } } }