恢复点(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>
244 lines
9.8 KiB
Java
244 lines
9.8 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.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;
|
||
}
|
||
}
|
||
}
|