Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/MeetingActionItemController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

244 lines
9.8 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<MeetingActionItem>> 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<MeetingActionItem> 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<List<MeetingActionItem>> 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<MeetingActionItem> existing = repo.findByMinuteIdOrderByIdAsc(req.minuteId());
List<MeetingActionItem> 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<MeetingActionItem> 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<MeetingActionItem> 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<Void> 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<List<OverdueItem>> overdue() {
LocalDate today = LocalDate.now();
List<OverdueItem> 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> board() {
List<MeetingActionItem> 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;
}
}
}