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

343 lines
16 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.Money;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.Patent;
import com.kaidi.oa.domain.Policy;
import com.kaidi.oa.domain.RdExpense;
import com.kaidi.oa.repository.OaSettingRepository;
import com.kaidi.oa.repository.PatentRepository;
import com.kaidi.oa.repository.PolicyRepository;
import com.kaidi.oa.repository.RdExpenseRepository;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Declarable-policy library (申报政策库) + a lightweight matcher that recommends
* policies for the enterprise. authority is the issuing body (科技 / 工信 / 发改 /
* 人社 …); category is one of 资质 / 资金 / 荣誉 / 人才; tags is a comma-separated
* keyword list. The matcher compares policy tags/title against an enterprise
* profile of keywords and orders hits by deadline (earliest first).
*
* 需甲方规则: 企业画像(标签集合)与匹配算法属甲方规则。当前为「标签关键词命中数 +
* 到期排序」的简化框架;企业标签优先读 setting key {@code policy-company-profile}
* 的 JSON 数组,缺省回退到 DEFAULT_PROFILE。生产应按资质年限/规模/行业打分。
*/
@RestController
@RequestMapping("/api/oa/policies")
public class PolicyController {
/** 需甲方规则: 默认企业画像标签。 */
static final List<String> DEFAULT_PROFILE = List.of("高企", "专精特新", "软件", "环保", "水务", "研发");
static final String PROFILE_SETTING_KEY = "policy-company-profile";
private final PolicyRepository policyRepo;
private final OaSettingRepository settingRepo;
private final ObjectMapper objectMapper;
private final PatentRepository patentRepo;
private final RdExpenseRepository rdExpenseRepo;
public PolicyController(PolicyRepository policyRepo, OaSettingRepository settingRepo,
ObjectMapper objectMapper, PatentRepository patentRepo,
RdExpenseRepository rdExpenseRepo) {
this.policyRepo = policyRepo;
this.settingRepo = settingRepo;
this.objectMapper = objectMapper;
this.patentRepo = patentRepo;
this.rdExpenseRepo = rdExpenseRepo;
}
@GetMapping
public ApiResp<List<Policy>> list(@RequestParam(required = false) String category,
@RequestParam(required = false) String authority,
@RequestParam(required = false) String status) {
List<Policy> list;
if (category != null && !category.isBlank()) {
list = policyRepo.findByCategory(category);
} else if (authority != null && !authority.isBlank()) {
list = policyRepo.findByAuthority(authority);
} else if (status != null && !status.isBlank()) {
list = policyRepo.findByStatus(status);
} else {
list = policyRepo.findAll();
}
return ApiResp.ok(list);
}
@GetMapping("/{id}")
public ApiResp<Policy> get(@PathVariable Long id) {
return ApiResp.ok(policyRepo.findById(id)
.orElseThrow(() -> new NotFoundException("policy not found: " + id)));
}
public record PolicyRequest(
String title, String authority, String category, String tags,
String conditions, Double fundAmount, String deadline, String status) {
}
@PostMapping
public ApiResp<Policy> create(@RequestBody PolicyRequest req) {
if (req.title() == null || req.title().isBlank()) {
throw new ApiException(400, "title is required");
}
Policy p = new Policy();
p.setTitle(req.title());
p.setAuthority(req.authority());
p.setCategory(req.category());
p.setTags(req.tags());
p.setConditions(req.conditions());
p.setFundAmount(Money.of(req.fundAmount() == null ? 0d : req.fundAmount()));
p.setDeadline(req.deadline());
p.setStatus(req.status() == null || req.status().isBlank() ? "可申报" : req.status());
p.setCreatedAt(Instant.now());
return ApiResp.ok(policyRepo.save(p));
}
@PutMapping("/{id}")
public ApiResp<Policy> update(@PathVariable Long id, @RequestBody PolicyRequest req) {
Policy p = policyRepo.findById(id)
.orElseThrow(() -> new NotFoundException("policy not found: " + id));
if (req.title() != null) {
if (req.title().isBlank()) {
throw new ApiException(400, "title is required");
}
p.setTitle(req.title());
}
if (req.authority() != null) p.setAuthority(req.authority());
if (req.category() != null) p.setCategory(req.category());
if (req.tags() != null) p.setTags(req.tags());
if (req.conditions() != null) p.setConditions(req.conditions());
if (req.fundAmount() != null) p.setFundAmount(Money.of(req.fundAmount()));
if (req.deadline() != null) p.setDeadline(req.deadline());
if (req.status() != null && !req.status().isBlank()) p.setStatus(req.status());
return ApiResp.ok(policyRepo.save(p));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
if (!policyRepo.existsById(id)) {
throw new NotFoundException("policy not found: " + id);
}
policyRepo.deleteById(id);
return ApiResp.ok(null);
}
/** 匹配结果:政策 + 命中的画像标签 + 命中数(matchScore)。 */
public record MatchedPolicy(Policy policy, List<String> matchedTags, int matchScore) {
}
/**
* GET /policies/match -> 按企业画像匹配可申报政策。画像标签来自 setting
* {@code policy-company-profile}JSON 数组),缺省用 DEFAULT_PROFILE;亦可用
* query 参数 tags=高企,软件 临时覆盖。命中规则:政策 tags/title 含画像标签即命中;
* 仅返回命中数>0 且非「已截止」的政策,按命中数降序、到期日升序排序。
*/
@GetMapping("/match")
public ApiResp<List<MatchedPolicy>> match(@RequestParam(required = false) String tags) {
List<String> profile = resolveProfile(tags);
List<MatchedPolicy> hits = new ArrayList<>();
for (Policy p : policyRepo.findAll()) {
if ("已截止".equals(p.getStatus())) continue;
String haystack = ((p.getTags() == null ? "" : p.getTags()) + " "
+ (p.getTitle() == null ? "" : p.getTitle())).toLowerCase();
List<String> matched = new ArrayList<>();
for (String tag : profile) {
if (tag != null && !tag.isBlank() && haystack.contains(tag.toLowerCase())) {
matched.add(tag);
}
}
if (!matched.isEmpty()) {
hits.add(new MatchedPolicy(p, matched, matched.size()));
}
}
hits.sort(Comparator
.comparingInt(MatchedPolicy::matchScore).reversed()
.thenComparing(m -> m.policy().getDeadline() == null ? "9999-12-31" : m.policy().getDeadline()));
return ApiResp.ok(hits);
}
/** GET /policies/profile -> 当前企业画像标签(供前端展示/编辑)。 */
@GetMapping("/profile")
public ApiResp<Map<String, Object>> profile() {
Map<String, Object> out = new LinkedHashMap<>();
out.put("tags", resolveProfile(null));
return ApiResp.ok(out);
}
/**
* 精准打分引擎(Gap 1 [low] 补完):在「标签命中数+截止排序」简化版基础上,
* 叠加企业规模/行业/知识产权年限维度,计算每条政策对本企业的综合匹配得分。
*
* <p>得分维度(总满分100):
* <ul>
* <li>标签命中(40分):每命中1个画像标签+8分,最高40分;</li>
* <li>企业规模匹配(20分):policy.conditions 或 tags 含规模关键词与传入 scale 匹配;</li>
* <li>行业匹配(20分):policy.conditions 或 tags 含行业关键词与传入 industry 匹配;</li>
* <li>知识产权年限(20分):有效 IP 数量 × 年限因子(有 IP 且未过期≥5年得满分)。</li>
* </ul>
* 参数全部可选:不传则该维度视为0分。
* 结果按总分降序排列,仅返回总分>0 且非「已截止」的政策。
*/
@GetMapping("/score")
public ApiResp<List<Map<String, Object>>> score(
@RequestParam(required = false) String tags,
@RequestParam(required = false) String industry,
@RequestParam(required = false) String scale,
@RequestParam(required = false) Boolean includeExpired) {
List<String> profile = resolveProfile(tags);
boolean showExpired = Boolean.TRUE.equals(includeExpired);
// 自动从知识产权库取有效 IP(打分用)
Set<String> validIpStatuses = Set.of("已受理", "已授权", "已转让");
long validIpCount = patentRepo.findAll().stream()
.filter(p -> p.getStatus() != null && validIpStatuses.contains(p.getStatus()))
.count();
// 知识产权有效年限:平均算到期日距今天数
long avgIpRemainingDays = patentRepo.findAll().stream()
.filter(p -> p.getStatus() != null && validIpStatuses.contains(p.getStatus())
&& p.getFeeDueDate() != null && !p.getFeeDueDate().isBlank())
.mapToLong(p -> {
try {
LocalDate due = LocalDate.parse(p.getFeeDueDate().substring(0, 10));
return ChronoUnit.DAYS.between(LocalDate.now(), due);
} catch (Exception e) {
return 0L;
}
})
.average()
.orElse(0.0) > 0 ? (long) patentRepo.findAll().stream()
.filter(p -> p.getStatus() != null && validIpStatuses.contains(p.getStatus())
&& p.getFeeDueDate() != null && !p.getFeeDueDate().isBlank())
.mapToLong(p -> {
try {
LocalDate due = LocalDate.parse(p.getFeeDueDate().substring(0, 10));
return Math.max(0, ChronoUnit.DAYS.between(LocalDate.now(), due));
} catch (Exception e) {
return 0L;
}
}).average().orElse(0.0) : 0L;
List<Map<String, Object>> result = new ArrayList<>();
for (Policy p : policyRepo.findAll()) {
if (!showExpired && "已截止".equals(p.getStatus())) {
continue;
}
String haystack = ((p.getTags() == null ? "" : p.getTags()) + " "
+ (p.getTitle() == null ? "" : p.getTitle())
+ " " + (p.getConditions() == null ? "" : p.getConditions())).toLowerCase();
// (1) 标签命中得分(max 40
List<String> matchedTags = new ArrayList<>();
for (String t : profile) {
if (t != null && !t.isBlank() && haystack.contains(t.toLowerCase())) {
matchedTags.add(t);
}
}
int tagScore = Math.min(40, matchedTags.size() * 8);
// (2) 行业匹配得分(max 20
int industryScore = 0;
if (industry != null && !industry.isBlank() && haystack.contains(industry.toLowerCase())) {
industryScore = 20;
}
// (3) 企业规模匹配(max 20
int scaleScore = 0;
if (scale != null && !scale.isBlank()) {
if (haystack.contains(scale.toLowerCase())) {
scaleScore = 20;
} else if ("中小企业".contains(scale) && (haystack.contains("中小") || haystack.contains("中型") || haystack.contains("小型"))) {
scaleScore = 10;
}
}
// (4) 知识产权年限得分(max 20)
int ipScore = 0;
if (validIpCount > 0) {
// IP 数量加成(每有 1 件有效 IP +4 分,最多 12)
ipScore += (int) Math.min(12, validIpCount * 4);
// 平均有效年限加成(剩余≥5年满 8 分;1-5年按比例;<1年 0
if (avgIpRemainingDays >= 365 * 5) {
ipScore += 8;
} else if (avgIpRemainingDays >= 365) {
ipScore += (int) (8.0 * avgIpRemainingDays / (365 * 5));
}
ipScore = Math.min(20, ipScore);
}
int totalScore = tagScore + industryScore + scaleScore + ipScore;
if (totalScore == 0) {
continue;
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("policyId", p.getId());
row.put("policyTitle", p.getTitle());
row.put("category", p.getCategory());
row.put("authority", p.getAuthority());
row.put("deadline", p.getDeadline());
row.put("status", p.getStatus());
row.put("fundAmount", Money.nz(p.getFundAmount()));
row.put("totalScore", totalScore);
row.put("tagScore", tagScore);
row.put("industryScore", industryScore);
row.put("scaleScore", scaleScore);
row.put("ipScore", ipScore);
row.put("matchedTags", matchedTags);
row.put("validIpCount", validIpCount);
result.add(row);
}
result.sort((a, b) -> Integer.compare((int) b.get("totalScore"), (int) a.get("totalScore")));
return ApiResp.ok(result);
}
private List<String> resolveProfile(String override) {
if (override != null && !override.isBlank()) {
return Arrays.stream(override.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList();
}
try {
return settingRepo.findBySettingKey(PROFILE_SETTING_KEY)
.map(s -> {
try {
JsonNode node = objectMapper.readTree(s.getValueJson());
if (node != null && node.isArray()) {
List<String> tags = new ArrayList<>();
node.forEach(n -> tags.add(n.asText()));
if (!tags.isEmpty()) return tags;
}
} catch (Exception ignore) {
// fall through to default
}
return DEFAULT_PROFILE;
})
.orElse(DEFAULT_PROFILE);
} catch (Exception e) {
return DEFAULT_PROFILE;
}
}
}