Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/LegalCrossDeptController.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

440 lines
20 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.Money;
import com.kaidi.oa.domain.ComplianceAlertRule;
import com.kaidi.oa.domain.HrNonCompete;
import com.kaidi.oa.domain.IpAsset;
import com.kaidi.oa.domain.LegalCreditInvest;
import com.kaidi.oa.domain.LitigationCase;
import com.kaidi.oa.repository.ComplianceAlertRuleRepository;
import com.kaidi.oa.repository.HrNonCompeteRepository;
import com.kaidi.oa.repository.IpAssetRepository;
import com.kaidi.oa.repository.LegalCreditInvestRepository;
import com.kaidi.oa.repository.LitigationCaseRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
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.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 法务风险部·与其他部门接口(需求 §9)。
*
* <p>补完三条跨部门联动缺口(Gap low 9):
* <ol>
* <li><b>销售合同客户信用风险预警</b>:销售部签约前调用
* {@code GET /api/oa/legal-cross-dept/sales-customer-check?customerName=} 自动聚合:
* - 法务专项调查结论(LegalCreditInvest
* - 在案诉讼信息(LitigationCase.opponent 模糊匹配)
* - 命中的合规预警规则(ComplianceAlertRule + 预警模拟)
* 返回综合风险评级(低/中/高/紧急)及处理建议,无需法务手动查阅三张表。</li>
*
* <li><b>HR 劳动纠纷案件自动建档</b>:HR 部门对离职员工或劳动纠纷发起快速建档,
* 调用 {@code POST /api/oa/legal-cross-dept/hr-labor-dispute-register} 一键在
* LitigationCase 中创建劳动仲裁案件记录,并同步将当事人竞业限制状态推进到「离职-补偿中」
* (若 employeeId 关联的 HrNonCompete 存在且状态为「在职-未触发」)。</li>
*
* <li><b>知识产权与法务状态同步看板</b>:
* {@code GET /api/oa/legal-cross-dept/ip-status-sync} 聚合 IpAsset 台账当前状态
* 映射到法务关注维度:即将失效专利、未缴年费、授权维持专利数;
* 供法务无需进入知识产权部系统即可掌握全局 IP 保护状态。</li>
* </ol>
*
* <p>所有读口包含敏感数据(涉诉/信用/专利),已纳入 SENSITIVE_READ_PREFIXES(见 sharedFileSnippets)。
*/
@RestController
@RequestMapping("/api/oa/legal-cross-dept")
public class LegalCrossDeptController {
private final LitigationCaseRepository caseRepo;
private final LegalCreditInvestRepository investRepo;
private final ComplianceAlertRuleRepository ruleRepo;
private final HrNonCompeteRepository nonCompeteRepo;
private final IpAssetRepository ipAssetRepo;
public LegalCrossDeptController(LitigationCaseRepository caseRepo,
LegalCreditInvestRepository investRepo,
ComplianceAlertRuleRepository ruleRepo,
HrNonCompeteRepository nonCompeteRepo,
IpAssetRepository ipAssetRepo) {
this.caseRepo = caseRepo;
this.investRepo = investRepo;
this.ruleRepo = ruleRepo;
this.nonCompeteRepo = nonCompeteRepo;
this.ipAssetRepo = ipAssetRepo;
}
// ========================================================================
// 1. 销售合同客户信用风险预警(销售部→法务部自动联动,Gap §9-1)
// ========================================================================
public record CustomerRiskItem(String category, String level, String content) {}
public record CustomerRiskResult(
String customerName,
String overallRiskLevel,
String suggestion,
boolean blacklisted,
boolean hasActiveLitigation,
int litigationCount,
BigDecimal litigationTotalAmount,
String latestInvestConclusion,
List<CustomerRiskItem> riskItems
) {}
/**
* 销售签约前客户涉诉/信用风险一键预检。
* 聚合三个数据源:法务调查结论 + 在案诉讼 + 合规预警规则,自动给出综合风险等级。
* 销售部门新签合同前、法务自动推送触发时均可调用,无需手工查询。
*/
@GetMapping("/sales-customer-check")
public ApiResp<CustomerRiskResult> salesCustomerCheck(
@RequestParam String customerName) {
if (customerName == null || customerName.isBlank()) {
throw new ApiException(400, "customerName 不能为空");
}
String name = customerName.trim();
// 法务调查结论(按客户名模糊)
List<LegalCreditInvest> invests = investRepo.findByCustomerNameContaining(name);
String latestConclusion = null;
boolean blacklisted = false;
for (LegalCreditInvest inv : invests) {
if (inv.isBlacklisted()) {
blacklisted = true;
}
if ("已结案".equals(inv.getStatus()) && inv.getConclusion() != null) {
latestConclusion = inv.getConclusion();
}
}
// 在案诉讼(按对方当事人名称模糊匹配)
List<LitigationCase> cases = caseRepo.findByOpponentContaining(name);
boolean hasActive = cases.stream().anyMatch(c -> !"已结案".equals(c.getStatus()));
BigDecimal totalAmt = BigDecimal.ZERO;
for (LitigationCase c : cases) {
totalAmt = Money.add(totalAmt, c.getAmount() == null ? BigDecimal.ZERO : c.getAmount());
}
// 命中合规预警规则(触发场景=销售)
List<ComplianceAlertRule> salesRules = ruleRepo.findByEnabled(Boolean.TRUE).stream()
.filter(r -> "销售".equals(r.getTriggerScene()) || "通用".equals(r.getTriggerScene()))
.toList();
// 构建风险条目
List<CustomerRiskItem> items = new ArrayList<>();
String overallLevel = "低";
if (blacklisted) {
items.add(new CustomerRiskItem("失信", "紧急", "客户已列入失信被执行人名单,禁止合作"));
overallLevel = "紧急";
}
if (hasActive) {
long activeCnt = cases.stream().filter(c -> !"已结案".equals(c.getStatus())).count();
items.add(new CustomerRiskItem("诉讼", "高",
"客户存在 " + activeCnt + " 起未结案诉讼,标的金额合计 " + totalAmt.toPlainString() + " 元"));
if (!"紧急".equals(overallLevel)) {
overallLevel = "高";
}
} else if (!cases.isEmpty()) {
items.add(new CustomerRiskItem("诉讼历史", "中",
"客户有 " + cases.size() + " 起历史诉讼记录(均已结案),注意合同条款设计"));
if ("低".equals(overallLevel)) {
overallLevel = "中";
}
}
if ("驳回".equals(latestConclusion)) {
items.add(new CustomerRiskItem("法务调查", "高", "法务专项调查结论为「驳回」,建议暂停合作"));
if (!"紧急".equals(overallLevel)) {
overallLevel = "高";
}
} else if ("需进一步调查".equals(latestConclusion)) {
items.add(new CustomerRiskItem("法务调查", "中", "法务专项调查尚需进一步核查,建议延缓签约"));
if ("低".equals(overallLevel)) {
overallLevel = "中";
}
} else if ("通过".equals(latestConclusion)) {
items.add(new CustomerRiskItem("法务调查", "低", "法务专项调查结论通过,信用状况良好"));
}
for (ComplianceAlertRule r : salesRules) {
items.add(new CustomerRiskItem("合规规则", r.getAlertLevel(),
"触发合规预警规则:" + r.getRuleName() + "" + r.getCondition() + ""));
if ("紧急".equals(r.getAlertLevel())) {
overallLevel = "紧急";
} else if ("高".equals(r.getAlertLevel()) && !"紧急".equals(overallLevel)) {
overallLevel = "高";
}
}
String suggestion;
switch (overallLevel) {
case "紧急" -> suggestion = "禁止签约。客户存在失信/重大法律风险,须经法务总监和总经理特批后方可进行任何商务往来。";
case "高" -> suggestion = "暂缓签约。需法务部门完成专项调查并出具法律意见书,合同须添加特殊担保/保函条款。";
case "中" -> suggestion = "谨慎签约。建议在合同中加强违约责任条款,设置分批付款里程碑,并持续跟踪诉讼进展。";
default -> suggestion = "可以签约。客户信用状况良好,按标准流程审批即可。";
}
return ApiResp.ok(new CustomerRiskResult(
name, overallLevel, suggestion,
blacklisted, hasActive, cases.size(), totalAmt,
latestConclusion == null ? "无记录" : latestConclusion,
items
));
}
// ========================================================================
// 2. HR 劳动纠纷案件快速建档(HR→法务自动联动,Gap §9-2)
// ========================================================================
public record LaborDisputeRegisterReq(
String employeeId,
String employeeName,
String dept,
String disputeType,
String description,
Double claimAmount,
String tribunal,
String filingDate,
String lawyer
) {}
public record LaborDisputeRegisterResult(
Long litigationCaseId,
String caseNo,
boolean nonCompeteTriggered,
String nonCompeteStatus,
String message
) {}
/**
* HR 劳动纠纷快速建档。
* 在 LitigationCase 中创建劳动仲裁案件,并自动检查:
* 若 employeeId 关联的 HrNonCompete 存在且状态为「在职-未触发」,
* 则自动将其推进到「离职-补偿中」状态(离职触发钩子),补完 Gap §9-2 手动建档问题。
*/
@PostMapping("/hr-labor-dispute-register")
@Transactional
public ApiResp<LaborDisputeRegisterResult> hrLaborDisputeRegister(
@RequestBody LaborDisputeRegisterReq req) {
if (req.employeeName() == null || req.employeeName().isBlank()) {
throw new ApiException(400, "employeeName 不能为空");
}
// 创建诉讼案件
LitigationCase kase = new LitigationCase();
kase.setCaseNo("LAB-" + System.currentTimeMillis() % 100000);
String disputeType = req.disputeType() == null || req.disputeType().isBlank()
? "劳动合同纠纷" : req.disputeType();
kase.setCaseName(req.employeeName() + disputeType + "案");
kase.setCause(disputeType);
kase.setOpponent(req.employeeName()
+ (req.dept() != null && !req.dept().isBlank() ? "" + req.dept() + "" : ""));
kase.setOurRole("被申请人");
kase.setTribunal(req.tribunal() == null || req.tribunal().isBlank()
? "广州市天河区劳动人事争议仲裁委员会" : req.tribunal());
kase.setAmount(Money.of(req.claimAmount()));
kase.setStage("诉前调解");
kase.setLawyer(req.lawyer() == null || req.lawyer().isBlank() ? "待指派" : req.lawyer());
kase.setFilingDate(req.filingDate() == null || req.filingDate().isBlank()
? java.time.LocalDate.now().toString() : req.filingDate());
kase.setResult("审理中");
kase.setStatus("进行中");
kase.setRemark("由 HR 系统劳动纠纷模块自动创建。当事人:" + req.employeeName()
+ (req.description() != null ? "" + req.description() : ""));
kase.setCreatedAt(Instant.now());
LitigationCase saved = caseRepo.save(kase);
// 自动触发竞业限制(联动钩子)
boolean ncTriggered = false;
String ncStatus = "无竞业限制记录";
if (req.employeeId() != null && !req.employeeId().isBlank()) {
List<HrNonCompete> ncs = nonCompeteRepo.findByEmployeeId(req.employeeId());
for (HrNonCompete nc : ncs) {
if ("在职-未触发".equals(nc.getStatus())) {
nc.setStatus("离职-补偿中");
String today = java.time.LocalDate.now().toString();
if (nc.getStartDate() == null || nc.getStartDate().isBlank()) {
nc.setStartDate(today);
if (nc.getRestrictionPeriod() != null && nc.getRestrictionPeriod() > 0) {
java.time.LocalDate end = java.time.LocalDate.now()
.plusMonths(nc.getRestrictionPeriod());
nc.setEndDate(end.toString());
}
}
nonCompeteRepo.save(nc);
ncTriggered = true;
ncStatus = "已自动推进为「离职-补偿中」";
break;
}
ncStatus = nc.getStatus();
}
}
String msg = "劳动仲裁案件已创建(案号 " + saved.getCaseNo() + ""
+ (ncTriggered ? ",竞业限制已自动触发" : "");
return ApiResp.ok(new LaborDisputeRegisterResult(
saved.getId(), saved.getCaseNo(), ncTriggered, ncStatus, msg));
}
// ========================================================================
// 3. 知识产权法务状态同步看板(IP部→法务部,Gap §9-3)
// ========================================================================
public record IpStatusItem(
Long id, String proposalNo, String name, String ipType,
String stage, String legalStatus, String grantDate,
double totalCost, String agency
) {}
public record IpStatusSyncResult(
int totalAssets,
int authorizedMaintaining,
int pendingFee,
int expiringSoon,
int invalidTerminated,
int inApplication,
List<IpStatusItem> atRiskAssets,
Map<String, Integer> byType,
Map<String, Integer> byLegalStatus
) {}
/**
* 知识产权法务状态同步看板。
* 从 IpAsset 台账聚合法务关注的核心维度,让法务部门无需进入知识产权部系统即可:
* - 掌握全量 IP 资产保护状态分布;
* - 快速识别已授权维持/即将失效/年费逾期的高风险资产;
* - 与法务诉讼台账(专利侵权案)进行对照分析。
* 补完 Gap §9-3:跨部门自动同步机制(只读聚合,无双写风险)。
*/
@GetMapping("/ip-status-sync")
public ApiResp<IpStatusSyncResult> ipStatusSync() {
List<IpAsset> all = ipAssetRepo.findAll();
int total = all.size();
int authorized = 0, pendingFee = 0, expiringSoon = 0, invalid = 0, inApp = 0;
Map<String, Integer> byType = new LinkedHashMap<>();
Map<String, Integer> byLegal = new LinkedHashMap<>();
List<IpStatusItem> atRisk = new ArrayList<>();
String today = java.time.LocalDate.now().toString();
String in90 = java.time.LocalDate.now().plusDays(90).toString();
for (IpAsset a : all) {
String ls = a.getLegalStatus() == null ? "未知" : a.getLegalStatus();
String stage = a.getStage() == null ? "未知" : a.getStage();
String ipType = a.getIpType() == null ? "其他" : a.getIpType();
byType.merge(ipType, 1, Integer::sum);
byLegal.merge(ls, 1, Integer::sum);
switch (ls) {
case "授权维持" -> authorized++;
case "申请中", "公开", "实审" -> inApp++;
case "失效终止", "无效宣告" -> invalid++;
}
// 到期预警:授权维持且授权日+20年在90天内(发明专利20年保护期近)
boolean atRiskFlag = false;
if ("授权维持".equals(ls) && a.getGrantDate() != null && !a.getGrantDate().isBlank()) {
try {
java.time.LocalDate gd = java.time.LocalDate.parse(a.getGrantDate().substring(0, 10));
java.time.LocalDate expiry = gd.plusYears(20);
String expiryStr = expiry.toString();
if (expiryStr.compareTo(today) >= 0 && expiryStr.compareTo(in90) <= 0) {
expiringSoon++;
atRiskFlag = true;
}
} catch (Exception ignored) {}
}
if ("无效宣告".equals(ls) || "失效终止".equals(ls)) {
atRiskFlag = true;
}
if (atRiskFlag) {
atRisk.add(new IpStatusItem(
a.getId(), a.getProposalNo(), a.getName(), a.getIpType(),
stage, ls, a.getGrantDate(),
a.getTotalCost() == null ? 0.0 : a.getTotalCost().doubleValue(),
a.getAgency()
));
}
}
return ApiResp.ok(new IpStatusSyncResult(
total, authorized, pendingFee, expiringSoon, invalid, inApp,
atRisk, byType, byLegal
));
}
// ========================================================================
// 4. 综合跨部门联动统计看板(Dashboard 聚合接口)
// ========================================================================
public record LegalCrossDeptDashboard(
// 诉讼看板
long litigationTotal,
long litigationActive,
double litigationTotalAmount,
// 竞业限制看板
long nonCompeteTotal,
long nonCompeteActive,
long nonCompeteViolated,
// 合规规则看板
long alertRuleTotal,
long alertRuleEnabled,
// 信用调查看板
long investTotal,
long investDriven,
// 知识产权看板
long ipTotal,
long ipAuthorized
) {}
/**
* 跨部门联动统计看板:法务部门首页仪表盘所需的综合数字,
* 一次调用返回诉讼/竞业/合规规则/信用调查/知识产权五个维度数字。
*/
@GetMapping("/dashboard")
public ApiResp<LegalCrossDeptDashboard> dashboard() {
List<LitigationCase> cases = caseRepo.findAll();
long litActive = cases.stream().filter(c -> !"已结案".equals(c.getStatus())).count();
BigDecimal litAmt = BigDecimal.ZERO;
for (LitigationCase c : cases) {
litAmt = Money.add(litAmt, c.getAmount() == null ? BigDecimal.ZERO : c.getAmount());
}
List<HrNonCompete> ncs = nonCompeteRepo.findAll();
long ncActive = ncs.stream().filter(nc ->
"离职-补偿中".equals(nc.getStatus()) || "在职-未触发".equals(nc.getStatus())).count();
long ncViolated = ncs.stream().filter(nc -> "已违约".equals(nc.getStatus())).count();
List<ComplianceAlertRule> rules = ruleRepo.findAll();
long ruleEnabled = rules.stream().filter(r -> Boolean.TRUE.equals(r.getEnabled())).count();
List<LegalCreditInvest> invests = investRepo.findAll();
long investDriven = invests.stream().filter(inv -> "驳回".equals(inv.getConclusion())).count();
List<IpAsset> ipAssets = ipAssetRepo.findAll();
long ipAuthorized = ipAssets.stream()
.filter(a -> "授权维持".equals(a.getLegalStatus())).count();
return ApiResp.ok(new LegalCrossDeptDashboard(
cases.size(), litActive, litAmt.doubleValue(),
ncs.size(), ncActive, ncViolated,
rules.size(), ruleEnabled,
invests.size(), investDriven,
ipAssets.size(), ipAuthorized
));
}
}