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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,342 @@
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;
}
}
}