绩效相关

This commit is contained in:
2026-10-08 14:34:39 +08:00
parent a98e297d90
commit a4df7e06f6
87 changed files with 5821 additions and 361 deletions
+6 -2
View File
@@ -282,7 +282,7 @@
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<version>1.18.8</version> <version>1.18.34</version>
</dependency> </dependency>
@@ -300,7 +300,11 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<!-- 不硬编码 skipTests:需要跳过测试时用命令行 mvn -DskipTests ... -->
</plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
@@ -19,6 +19,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.poi.ss.usermodel.*; import org.apache.poi.ss.usermodel.*;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -106,6 +107,7 @@ public class CommonsController {
f.setTitle(title); f.setTitle(title);
f.setSize(bytes.length); f.setSize(bytes.length);
f.setPathname(baseUrl+"/zentao/img/"+fName); f.setPathname(baseUrl+"/zentao/img/"+fName);
f.setUrl("/zentao/img/"+fName); // url 存相对路径:经前端源/代理可达,避免绝对 baseUrl 在本地环境不可达(与 uploadBind 一致)
f.setObjectid(file.getObjectId()); f.setObjectid(file.getObjectId());
fileService.save(f); fileService.save(f);
@@ -115,6 +117,77 @@ public class CommonsController {
} }
} }
/**
* 上传+绑定一步完成(AI 无表单场景):写磁盘+插 zt_file(含 objectType/objectID/url),
* 并按 objectType 刷新主表访问链接字段(zt_story 7 url 列+code_review_status / zt_meeting.url)
* @param file 入参:file 文件、objectType 类型、objectId 主表id、title 可选、reviewResult 可选(aiCodeReview 用)
* @return zt_file 记录(含 id 与 url)
*/
@PostMapping("/uploadBind")
@Transactional
public Result<ZtFile> uploadBind(UploadDTO file){
//上传必须登录(AI 通道带 ai token;UI 带用户 token),文件/动态创建人取 token 身份(CHG-061)
if (RiskUserThreadLocal.get() == null || StringUtils.isEmpty(RiskUserThreadLocal.get().getName())) {
return Result.fail("请登录(上传需携带有效token)");
}
if (file.getFile().isEmpty()) {
return Result.fail(Code.FAIL);
}
if (StringUtils.isEmpty(file.getObjectType()) || file.getObjectId() == null) {
return Result.fail("objectType/objectId不能为空");
}
// CHG-077:story 一律归一为 storyPrd(uploadBind 仅 AI 通道使用;UI 手动附件走两段式上传不进此接口)
file.normalizeObjectTypeForBind();
try {
// 获取文件名
String fName = file.getFile().getOriginalFilename();
String title=StringUtils.isEmpty(file.getTitle())?fName:file.getTitle();
fName=fName.substring(fName.lastIndexOf("."),fName.length());
String extension=fName.toString();
fName= DateUtils.formatDate(new Date(),"yyyyMMddHHmmss")+UUID.randomUUID().toString().replaceAll("-","")+fName;
// 获取文件的字节
byte[] bytes = file.getFile().getBytes();
String p ="";
String os = System.getProperty("os.name");
if (os.toLowerCase().startsWith("win")) { //如果是Windows系统
log.info("win");
os=windowsFilePath +"\\";
}else{ //linux和mac系统
log.info("linux");
os=linuxFilePath+"/";
}
String url = "/zentao/img/" + fName; // 相对路径:经前端源/代理可达,规避跨域(baseUrl 绝对地址浏览器未必可达)
// 先校验 objectType/objectId 并刷新主表字段,非法则抛异常整体回滚不落盘
fileService.refreshOwnerUrl(file.getObjectType(), file.getObjectId(), url, file.getReviewResult());
// 使用文件名在服务器端创建文件
java.nio.file.Path path = java.nio.file.Paths.get(os + fName);
// 写入文件到服务器的指定目录
java.nio.file.Files.write(path, bytes);
ZtFile f=new ZtFile();
f.setAddedby(RiskUserThreadLocal.get()==null?"ai":RiskUserThreadLocal.get().getName());
f.setAddeddate(new Date());
f.setDeleted("0");
f.setExtension(extension);
f.setTitle(title);
f.setSize(bytes.length);
f.setPathname(url);
f.setUrl(url);
f.setObjecttype(file.getObjectType());
f.setObjectid(file.getObjectId());
fileService.save(f);
return Result.success(f);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("文件上传失败");
}
}
@PostMapping("/copyFile") @PostMapping("/copyFile")
public Result copyFile( UploadDTO file){ public Result copyFile( UploadDTO file){
String files = file.getFiles(); String files = file.getFiles();
@@ -190,9 +263,11 @@ public class CommonsController {
String substring = pathname.substring(pathname.lastIndexOf("/")+1, pathname.length()); String substring = pathname.substring(pathname.lastIndexOf("/")+1, pathname.length());
try { try {
File f = new File(linuxFilePath+"/"+substring); // 与上传保持一致的 OS 路径选择(原硬编码 linuxFilePath 导致 Windows 本地下载全部失败)
// File f = new File("D:\\1.bmp"); String osName = System.getProperty("os.name");
InputStream fis = new BufferedInputStream(new FileInputStream(linuxFilePath+"/"+substring)); String basePath = osName.toLowerCase().startsWith("win") ? windowsFilePath : linuxFilePath;
File f = new File(basePath + File.separator + substring);
InputStream fis = new BufferedInputStream(new FileInputStream(f));
// InputStream fis = new BufferedInputStream(new FileInputStream("D:\\1.bmp")); // InputStream fis = new BufferedInputStream(new FileInputStream("D:\\1.bmp"));
byte[] buffer = new byte[fis.available()]; byte[] buffer = new byte[fis.available()];
@@ -4,18 +4,22 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.dao.*; import com.sa.zentao.dao.*;
import com.sa.zentao.entity.ZtProject; import com.sa.zentao.entity.ZtProject;
import com.sa.zentao.qo.ZtCountQo; import com.sa.zentao.qo.ZtCountQo;
import com.sa.zentao.qo.ZtDocCheckQo;
import com.sa.zentao.qo.ZtProjectQo; import com.sa.zentao.qo.ZtProjectQo;
import com.sa.zentao.service.IZtDocCheckService;
import com.sa.zentao.service.IZtProjectService; import com.sa.zentao.service.IZtProjectService;
import com.sa.zentao.service.impl.IZtCountService; import com.sa.zentao.service.impl.IZtCountService;
import com.sa.zentao.utils.DateUtils; import com.sa.zentao.utils.DateUtils;
import jakarta.servlet.ServletOutputStream; import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -35,6 +39,9 @@ public class ZtCountController {
@Autowired @Autowired
private IZtProjectService projectService; private IZtProjectService projectService;
@Autowired
private IZtDocCheckService docCheckService;
/** /**
* 开发看到的 * 开发看到的
@@ -205,4 +212,48 @@ public class ZtCountController {
return Result.success(countService.monthScopeByProgram(qo)); return Result.success(countService.monthScopeByProgram(qo));
} }
//工作量指标完成率(FR-012 三期):Σ(当月 workload 增量) ÷ (团队可用工作天数×5),测试人员不计入产出方
@RequestMapping(value = "/workloadRate", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
public Result workloadRate(@RequestParam(required = false) String month, @RequestParam Integer projectId){
return Result.success(countService.workloadRate(month, projectId));
}
//手动触发文档齐备快照(FR-014 三期),月末 28 日 02:00 另有定时预生成
@RequestMapping(value = "/generateDocCheck", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result generateDocCheck(@RequestBody ZtDocCheckQo qo){
String month = StringUtils.isEmpty(qo.getMonth()) ? DateUtils.formatDate(new Date(), "yyyy-MM") : qo.getMonth();
return Result.success(docCheckService.generateMonthSnapshot(month));
}
//文档齐备申诉(FR-014):状态流 pending → appealing
@RequestMapping(value = "/docCheckAppeal", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result docCheckAppeal(@RequestBody ZtDocCheckQo qo){
docCheckService.appeal(qo.getStoryId(), qo.getMonth(), qo.getDocType(), qo.getReason());
return Result.success();
}
//文档齐备申诉复核(FR-014):appealing → resolved,复核通过回滚扣分
@RequestMapping(value = "/docCheckAppealResolve", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result docCheckAppealResolve(@RequestBody ZtDocCheckQo qo){
docCheckService.resolveAppeal(qo.getStoryId(), qo.getMonth(), qo.getDocType(),
qo.getPass() != null && qo.getPass(), qo.getRemark());
return Result.success();
}
//文档齐备矩阵页数据(FR-014 三期):按需求聚合五类判定/扣分/状态
@RequestMapping(value = "/docCheckMatrix", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
public Result docCheckMatrix(String month){
if (StringUtils.isEmpty(month)) {
month = DateUtils.formatDate(new Date(), "yyyy-MM");
}
return Result.success(docCheckService.listMatrix(month));
}
//月度确认(FR-014 三期):项目经理确认后该月快照锁定
@RequestMapping(value = "/docCheckConfirm", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result docCheckConfirm(@RequestBody ZtDocCheckQo qo){
docCheckService.confirmMonth(qo.getMonth());
return Result.success();
}
} }
@@ -0,0 +1,176 @@
package com.sa.zentao.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.conf.RiskUserThreadLocal;
import com.sa.zentao.dao.BusinessException;
import com.sa.zentao.dao.Result;
import com.sa.zentao.entity.ZtPerfConfig;
import com.sa.zentao.qo.ZtPerfQo;
import com.sa.zentao.service.IZtPerfConfigService;
import com.sa.zentao.service.IZtPerfReportService;
import com.sa.zentao.service.perf.PerfMonthScope;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
/**
* <p>
* 绩效报表(三期批2:报表引擎+接口) 前端控制器
* </p>
*
* @author ai
* @since 2026-07-28
*/
@RestController
@RequestMapping("/zt-perf")
public class ZtPerfController {
@Autowired
private IZtPerfReportService perfReportService;
@Autowired
private IZtPerfConfigService perfConfigService;
/**
* 按岗位 tab 报表数据(指标/权重/得分/数据来源/明细)
*/
@RequestMapping(value = "/report", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
public Result<PerfMonthScope> report(@RequestParam String month, @RequestParam String role, @RequestParam(required = false) String account) {
return Result.success(perfReportService.report(month, role, account));
}
/**
* 批量生成/重算月度得分写 zt_month_score.scopeJson
*/
@RequestMapping(value = "/generateMonthScore", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result generateMonthScore(@RequestBody ZtPerfQo qo) {
perfReportService.generateMonthScore(qo.getMonth(), qo.getRole(), qo.getAccount());
return Result.success();
}
/**
* 人工评分合并写 scopeJson
*/
@RequestMapping(value = "/manualScore", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result manualScore(@RequestBody ZtPerfQo qo) {
perfReportService.manualScore(qo);
return Result.success();
}
/**
* 申诉登记(写 scopeJson.appeals)
*/
@RequestMapping(value = "/appeal", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result appeal(@RequestBody ZtPerfQo qo) {
perfReportService.appeal(qo);
return Result.success();
}
/**
* 申诉复核:upheld 维持 / overturned 撤销回滚
*/
@RequestMapping(value = "/appealReview", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result appealReview(@RequestBody ZtPerfQo qo) {
perfReportService.appealReview(qo);
return Result.success();
}
/**
* 绩效规则配置列表(可按岗位过滤)
*/
@RequestMapping(value = "/config/list", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
public Result<List<ZtPerfConfig>> configList(@RequestParam(required = false) String role) {
QueryWrapper<ZtPerfConfig> wrapper = new QueryWrapper<>();
if (StringUtils.isNotBlank(role)) {
wrapper.eq("role", role);
}
wrapper.orderByAsc("role", "id");
return Result.success(perfConfigService.list(wrapper));
}
/**
* 新增配置(仅 admin)
*/
@RequestMapping(value = "/config/add", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result configAdd(@RequestBody ZtPerfQo qo) {
checkAdmin();
ZtPerfConfig config = buildConfig(qo, new ZtPerfConfig());
Date now = new Date();
String operator = RiskUserThreadLocal.get().getName();
config.setCreateTime(now);
config.setCreateUser(operator);
config.setUpdateTime(now);
config.setUpdateUser(operator);
perfConfigService.save(config);
return Result.success();
}
/**
* 修改配置(仅 admin)
*/
@RequestMapping(value = "/config/updateById", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result configUpdate(@RequestBody ZtPerfQo qo) {
checkAdmin();
if (StringUtils.isBlank(qo.getId())) {
throw new BusinessException("id 不能为空");
}
ZtPerfConfig config = perfConfigService.getById(Integer.valueOf(qo.getId()));
if (config == null) {
throw new BusinessException("配置不存在:" + qo.getId());
}
buildConfig(qo, config);
config.setUpdateTime(new Date());
config.setUpdateUser(RiskUserThreadLocal.get().getName());
perfConfigService.updateById(config);
return Result.success();
}
/**
* 删除配置(仅 admin)
*/
@RequestMapping(value = "/config/delete", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result configDelete(@RequestBody ZtPerfQo qo) {
checkAdmin();
if (StringUtils.isBlank(qo.getId())) {
throw new BusinessException("id 不能为空");
}
perfConfigService.removeById(Integer.valueOf(qo.getId()));
return Result.success();
}
private ZtPerfConfig buildConfig(ZtPerfQo qo, ZtPerfConfig config) {
if (StringUtils.isNotBlank(qo.getRole())) {
config.setRole(qo.getRole());
}
if (StringUtils.isNotBlank(qo.getItemKey())) {
config.setItemKey(qo.getItemKey());
}
if (StringUtils.isNotBlank(qo.getItemName())) {
config.setItemName(qo.getItemName());
}
if (qo.getWeight() != null) {
config.setWeight(BigDecimal.valueOf(qo.getWeight()));
}
if (StringUtils.isNotBlank(qo.getRuleJson())) {
config.setRuleJson(qo.getRuleJson());
}
if (qo.getEnabled() != null) {
config.setEnabled(qo.getEnabled());
}
return config;
}
private void checkAdmin() {
if (!"admin".equals(RiskUserThreadLocal.get().getName())) {
throw new BusinessException("仅管理员可维护绩效配置");
}
}
}
@@ -3,6 +3,7 @@ package com.sa.zentao.controller;
import com.sa.zentao.dao.Result; import com.sa.zentao.dao.Result;
import com.sa.zentao.entity.ZtStoryExpand; import com.sa.zentao.entity.ZtStoryExpand;
import com.sa.zentao.service.IZtStoryExpandService; import com.sa.zentao.service.IZtStoryExpandService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -49,6 +50,20 @@ public class ZtStoryExpandController {
return Result.success(list); return Result.success(list);
} }
/**
* 按需求 ID 单查(AI 指标区块用)
* @param storyId 需求 ID
* @return 需求扩展信息(无记录返回 data=null)
*/
@RequestMapping(value = "/queryByStoryId", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
public Result<ZtStoryExpand> queryByStoryId(Integer storyId) {
if (storyId == null) {
return Result.fail("storyId不能为空");
}
return Result.success(ztStoryExpandService.getOne(
new QueryWrapper<ZtStoryExpand>().lambda().eq(ZtStoryExpand::getStoryId, storyId)));
}
/** /**
* 保存或更新(有数据了更新不新增) * 保存或更新(有数据了更新不新增)
* @param entity 需求扩展信息 * @param entity 需求扩展信息
@@ -100,6 +100,16 @@ public class ZtTaskController {
return Result.success(); return Result.success();
} }
/**
* AI 批量创建任务:storyId 不存在或 type 非 devel/test 整批拒绝;重复跳过记 skipped
* @param dto 报文 {"storyId":N,"tasks":[{"name","type","assignedTo","aiEvaluationTime","planStartDate","deadline"}]}
* @return {"code":0,"data":{"created":N,"taskIds":[...],"skipped":[...]}}
*/
@RequestMapping(value = "/aiBatchAdd", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result aiBatchAdd(@RequestBody ZtTaskAiBatchDTO dto){
return Result.success(ztTaskService.aiBatchAdd(dto));
}
@RequestMapping(value = "/approval", method = RequestMethod.POST, produces = "application/json; charset=UTF-8") @RequestMapping(value = "/approval", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
public Result approval(@RequestBody ZtTaskDTO dto){ public Result approval(@RequestBody ZtTaskDTO dto){
ztTaskService.approval(dto); ztTaskService.approval(dto);
@@ -139,6 +139,8 @@ public class ZtUserController {
.eq(ZtUser::getAccount,RiskUserThreadLocal.get().getName())); .eq(ZtUser::getAccount,RiskUserThreadLocal.get().getName()));
} }
eq.eq(dto.getUserType()!=null,ZtUser::getUserType,dto.getUserType()); eq.eq(dto.getUserType()!=null,ZtUser::getUserType,dto.getUserType());
//CHG-073:userList 剔除 password 列——MD5 哈希不得下发前端(按属性名匹配,实体列名带反引号)
eq.select(ZtUser.class, fieldInfo -> !"password".equals(fieldInfo.getProperty()));
return Result.success(userService.list(eq)); return Result.success(userService.list(eq));
} }
@@ -182,4 +182,31 @@ public class PerformanceDTO implements Serializable {
private BigDecimal productPlanStoryNum; private BigDecimal productPlanStoryNum;
//本阶段准时发布研发数量 //本阶段准时发布研发数量
private BigDecimal productPlanStoryOnTimeNum; private BigDecimal productPlanStoryOnTimeNum;
/** ===== CHG-037 新 Excel 角色改造新增字段(score×weight 加权得分) ===== */
//需求PRD工作量指标完成率得分(项目经理20/产品经理40/产品助理50)
private BigDecimal workloadPrdScore;
//团队工作量指标完成率得分(项目经理30/产品经理20)
private BigDecimal workloadTeamScore;
//运维大项任务及时完成得分(20)
private BigDecimal opsMajorTaskScore;
//系统运维监控得分(15)
private BigDecimal opsMonitorScore;
//职场巡检得分(10)
private BigDecimal opsInspectScore;
//数据库备份得分(10)
private BigDecimal opsBackupScore;
//其他运维工作得分(15,人工)
private BigDecimal otherOpsScore;
//开发方向(CHG-038,KFZ 弹窗分流用):frontend=前端 / backend=后端
private String devDirection;
//CHG-041 指标过程值明细(itemKey → "分子/分母/率"),弹窗「绩效数据」列展示
private java.util.Map<String, String> perfRawDetail;
/** ===== CHG-044 达标工时全链路(弹窗「绩效数据」列展示分子分母) ===== */
//团队总工作天数(人·天,=工作天数×KFZ 人数 − 团队请假折算天数)
private BigDecimal teamWorkDays;
//团队请假折算天数(团队请假小时 ÷8)
private BigDecimal teamLeaveDays;
//团队达标总工时(=团队总工作天数 ×5)
private BigDecimal teamTargetTime;
} }
@@ -26,6 +26,20 @@ public class UploadDTO implements Serializable {
private Integer objectId; private Integer objectId;
//代码审查结果 pass/reject(objectType=aiCodeReview 时可选传入)
private String reviewResult;
private List<Integer> testIds; private List<Integer> testIds;
/**
* uploadBind 入口归一化(CHG-077):story 一律按 storyPrd 处理。
* uploadBind 仅 AI 通道使用(UI 手动附件走两段式上传不进此接口),
* 归一后文件进详情页「需求文档」区块并刷新 prd_url,不再落通用附件区。
*/
public void normalizeObjectTypeForBind() {
if ("story".equals(this.objectType)) {
this.objectType = "storyPrd";
}
}
} }
@@ -378,4 +378,22 @@ public class ZtStoryDTO implements Serializable {
*/ */
@ExcelIgnore @ExcelIgnore
private String deliverContent; private String deliverContent;
// ===== AI 文档链接与审查状态(对应 zt_story 新列,详见 dev_plan v4.0 §3.1)=====
/** PRD 文档访问链接 */
private String prdUrl;
/** 代码审查报告访问链接 */
private String codeReviewUrl;
/** 工作日志访问链接 */
private String workLogUrl;
/** 测试用例访问链接 */
private String testCaseUrl;
/** 测试报告模版下载访问链接 */
private String testReportDownloadUrl;
/** 测试报告提交访问链接 */
private String testReportSubmitUrl;
/** 其他测试文档访问链接 */
private String testOtherUrl;
/** 审查状态 pass/reject */
private String codeReviewStatus;
} }
@@ -57,41 +57,46 @@ public class ZtStoryUserDTO implements Serializable {
private String storyList; private String storyList;
@ExcelIgnore @ExcelIgnore
private Set<ZtProject> execList; private Set<ZtProject> execList;
@ExcelProperty(value = "状态",index =5) /**
* 迭代版本(CHG-078):execList 中迭代名称去重排序逗号拼接,仅供导出/列表展示
*/
@ExcelProperty(value = "迭代版本", index = 5)
private String execNames;
@ExcelProperty(value = "状态",index =6)
private String status; private String status;
@ExcelProperty(value = "来源",index =6) @ExcelProperty(value = "来源",index =7)
private String source; private String source;
@ExcelProperty(value = "期望完成时间",index =7) @ExcelProperty(value = "期望完成时间",index =8)
private Date planEndDate; private Date planEndDate;
/** /**
* 验收人 * 验收人
*/ */
@ExcelIgnore @ExcelIgnore
private String ysUser; private String ysUser;
@ExcelProperty(value = "验收人",index =8) @ExcelProperty(value = "验收人",index =9)
private String ysUserName; private String ysUserName;
@ExcelProperty(value = "评审人",index =9) @ExcelProperty(value = "评审人",index =10)
private String reviewedbyName; private String reviewedbyName;
@ExcelProperty(value = "创建日期",index =10) @ExcelProperty(value = "创建日期",index =11)
private Date openeddate; private Date openeddate;
@ExcelProperty(value = "更新日期",index =11) @ExcelProperty(value = "更新日期",index =12)
private Date lastediteddate; private Date lastediteddate;
@ExcelIgnore @ExcelIgnore
private String closedby; private String closedby;
@ExcelProperty(value = "由谁关闭",index =12) @ExcelProperty(value = "由谁关闭",index =13)
private String closedbyName; private String closedbyName;
@ExcelIgnore @ExcelIgnore
private Date closeddate; private Date closeddate;
@ExcelProperty(value = "关闭原因",index =13) @ExcelProperty(value = "关闭原因",index =14)
private String closedreason; private String closedreason;
@ExcelProperty(value = "需求链接",index =14) @ExcelProperty(value = "需求链接",index =15)
private WriteCellData<String> url; private WriteCellData<String> url;
@ExcelIgnore @ExcelIgnore
private String vision; private String vision;
@@ -0,0 +1,39 @@
package com.sa.zentao.dao;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.List;
/**
* AI 批量创建任务报文(POST /zt-task/aiBatchAdd)
* {"storyId":N,"tasks":[{"name","type","assignedTo","aiEvaluationTime","planStartDate","deadline"}]}
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class ZtTaskAiBatchDTO implements Serializable {
private Integer storyId;
private List<AiTaskItem> tasks;
@Data
@EqualsAndHashCode(callSuper = false)
public static class AiTaskItem implements Serializable {
private String name;
//devel 开发 / test 测试 / design 设计(CHG-081 放行)/ affair 事务(CHG-082 放行)
private String type;
private String assignedTo;
//任务描述,可选,写入 zt_task.desc
private String desc;
//AI评估工时,写入 zt_task.estimate
private Float aiEvaluationTime;
//预计开始,格式 yyyy-MM-dd,写入 zt_task.estStarted
private String planStartDate;
//预计完成,格式 yyyy-MM-dd,写入 zt_task.deadline
private String deadline;
}
}
@@ -128,6 +128,9 @@ public class ZtUserDTO implements Serializable {
private UserType userType; private UserType userType;
//开发方向(CHG-038,仅 KFZ):frontend=前端 / backend=后端
private String devDirection;
private String userTypeValue; private String userTypeValue;
private String roleTypeValue; private String roleTypeValue;
@@ -0,0 +1,114 @@
package com.sa.zentao.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
/**
* <p>
* 大型需求文档齐备核查快照表(FR-014 三期)
* 一行 = 某大型需求某月某类文档的核查结果;
* 状态流 pending(待确认)→ appealing(申诉中)→ resolved(复核完结,appealResult=pass 时回滚扣分)
* </p>
*
* @author ai
* @since 2026-07-28
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class ZtDocCheck implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 状态-待确认
*/
public static final String STATUS_PENDING = "pending";
/**
* 状态-申诉中
*/
public static final String STATUS_APPEALING = "appealing";
/**
* 状态-复核完结
*/
public static final String STATUS_RESOLVED = "resolved";
/**
* 复核结果-通过(回滚扣分)
*/
public static final String APPEAL_PASS = "pass";
/**
* 复核结果-驳回
*/
public static final String APPEAL_REJECT = "reject";
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
/**
* 研发需求ID(zt_story.id,大型需求=workload_index>20)
*/
private Integer storyId;
/**
* 考核月份 yyyy-MM
*/
private String month;
/**
* 文档类型 testCase/testReport/aiCodeReview/aiWorkLog/docUpdate
*/
private String docType;
/**
* 是否齐备 1有 0缺失
*/
private Integer present;
/**
* 判定明细(命中记录说明)
*/
private String detail;
/**
* 状态 pending/appealing/resolved
*/
private String status;
/**
* 申诉理由
*/
private String appealReason;
/**
* 复核结果 pass/reject(pass 回滚扣分)
*/
private String appealResult;
/**
* 复核备注
*/
private String resolveRemark;
/**
* 核查时间
*/
private Date checkTime;
private Date createTime;
private Date updateTime;
/**
* 是否计入缺失扣分(缺失 且 非复核通过)
*/
public boolean countsAsMissing() {
return (this.present == null || this.present == 0)
&& !(STATUS_RESOLVED.equals(this.status) && APPEAL_PASS.equals(this.appealResult));
}
}
@@ -29,6 +29,9 @@ public class ZtFile implements Serializable {
private String pathname; private String pathname;
//附件访问链接
private String url;
//文件名 //文件名
private String title; private String title;
//后缀 //后缀
@@ -73,4 +73,9 @@ public class ZtMeeting implements Serializable {
private String result; private String result;
private String meetingAfter; private String meetingAfter;
/**
* 会议纪要MD访问链接(最新一份)
*/
private String url;
} }
@@ -0,0 +1,69 @@
package com.sa.zentao.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* <p>
* 绩效指标规则配置表(三期,prd §7.5 九岗位算分模型)
* 一行 = 某岗位某指标项;rule_json 存规则参数(type: linear/threshold/manual)
* </p>
*
* @author ai
* @since 2026-07-28
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class ZtPerfConfig implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
/**
* 岗位编码(projectManager/productManager/backendDev/tester/uiEngineer/opsEngineer 等)
*/
private String role;
/**
* 指标编码(与 PerfIndicatorCalculator.key() 对应)
*/
private String itemKey;
/**
* 指标名称
*/
private String itemName;
/**
* 权重(0~1,加分项为0)
*/
private BigDecimal weight;
/**
* 规则参数JSON(type=linear/threshold/manual)
*/
private String ruleJson;
/**
* 是否启用(加分项可配开关)
*/
private Integer enabled;
private String createUser;
private Date createTime;
private String updateUser;
private Date updateTime;
}
@@ -240,4 +240,49 @@ public class ZtStory implements Serializable {
* 测试人员 * 测试人员
*/ */
private String testUser; private String testUser;
/**
* PRD文档访问链接
*/
@TableField("prd_url")
private String prdUrl;
/**
* 代码审查报告访问链接
*/
@TableField("code_review_url")
private String codeReviewUrl;
/**
* 工作日志访问链接
*/
@TableField("work_log_url")
private String workLogUrl;
/**
* AI项目文档更新记录访问链接(CHG-046)
*/
@TableField("ai_doc_update_url")
private String aiDocUpdateUrl;
/**
* 测试用例访问链接
*/
@TableField("test_case_url")
private String testCaseUrl;
/**
* 测试报告模版下载访问链接
*/
@TableField("test_report_download_url")
private String testReportDownloadUrl;
/**
* 测试报告提交访问链接
*/
@TableField("test_report_submit_url")
private String testReportSubmitUrl;
/**
* 其他测试文档访问链接
*/
@TableField("test_other_url")
private String testOtherUrl;
/**
* 审查状态 pass/reject
*/
@TableField("code_review_status")
private String codeReviewStatus;
} }
@@ -48,6 +48,11 @@ public class ZtStoryExpand implements Serializable {
*/ */
private String aiEfficiencyCoefficient; private String aiEfficiencyCoefficient;
/**
* AI参与率(只存不算,口径待定)
*/
private String aiParticipationRate;
/** /**
* 评估工时 * 评估工时
*/ */
@@ -115,4 +120,10 @@ public class ZtStoryExpand implements Serializable {
*/ */
private String testPerson; private String testPerson;
/**
* AI框架验收指标(Given/When/Then,MD 文本;框架经 saveOrUpdate 上传,
* 与老验收标准 zt_storyspec.verify 互不干扰——CHG-022)
*/
private String acceptanceCriteria;
} }
@@ -126,6 +126,9 @@ public class ZtUser implements Serializable {
private UserType userType; private UserType userType;
//开发方向(CHG-038,仅 user_type=KFZ 有效):frontend=前端 / backend=后端,NULL 按后端口径
private String devDirection;
private String productIds; private String productIds;
@TableField(exist = false) @TableField(exist = false)
@@ -13,6 +13,14 @@ public enum FileTypes {
feedbackStory("feedbackStory", "问题反馈"), feedbackStory("feedbackStory", "问题反馈"),
ywTask("ywTask", "运维任务"), ywTask("ywTask", "运维任务"),
meeting("meeting", "会议"), meeting("meeting", "会议"),
aiCodeReview("aiCodeReview", "代码审查报告"),
aiWorkLog("aiWorkLog", "工作日志"),
aiDocUpdate("aiDocUpdate", "AI项目文档更新记录"),
storyPrd("storyPrd", "需求文档"),
testCase("testCase", "测试用例"),
testReport("testReport", "测试报告模版"),
testReportSubmit("testReportSubmit", "测试报告提交"),
testOther("testOther", "其他测试文档"),
; ;
@EnumValue @EnumValue
@@ -0,0 +1,16 @@
package com.sa.zentao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sa.zentao.entity.ZtDocCheck;
/**
* <p>
* Mapper 接口
* </p>
*
* @author ai
* @since 2026-07-28
*/
public interface ZtDocCheckMapper extends BaseMapper<ZtDocCheck> {
}
@@ -0,0 +1,16 @@
package com.sa.zentao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sa.zentao.entity.ZtPerfConfig;
/**
* <p>
* Mapper 接口
* </p>
*
* @author ai
* @since 2026-07-28
*/
public interface ZtPerfConfigMapper extends BaseMapper<ZtPerfConfig> {
}
@@ -28,6 +28,12 @@ public interface ZtTaskMapper extends BaseMapper<ZtTask> {
List<ItApproval> itApprovalByUserName(@Param("name") String name, @Param("startDate") Date firstDayOfMonth, @Param("endDate") Date lastDayOfMonth); List<ItApproval> itApprovalByUserName(@Param("name") String name, @Param("startDate") Date firstDayOfMonth, @Param("endDate") Date lastDayOfMonth);
/**
* 按姓名批量查询请假审批(条件与 itApprovalByUserName 完全一致,仅 name 改为 IN 批量;
* 供 storyBarChart 月度循环批量化使用,老方法保留不动)
*/
List<ItApproval> itApprovalsByNames(@Param("names") List<String> names, @Param("startDate") Date firstDayOfMonth, @Param("endDate") Date lastDayOfMonth);
List<ZtTask> taskListByIds(@Param("ids") List<String> ids); List<ZtTask> taskListByIds(@Param("ids") List<String> ids);
List<ZtTask> taskListByStoryIds(@Param("ids") List<Integer> sIds); List<ZtTask> taskListByStoryIds(@Param("ids") List<Integer> sIds);
@@ -0,0 +1,52 @@
package com.sa.zentao.qo;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
/**
* <p>
* 文档齐备核查入参(FR-014 三期):generateDocCheck 用 month;
* docCheckAppeal 用 storyId/month/docType/reason;docCheckAppealResolve 另加 pass/remark
* </p>
*
* @author ai
* @since 2026-07-28
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class ZtDocCheckQo implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 月份 yyyy-MM
*/
private String month;
/**
* 需求ID
*/
private Integer storyId;
/**
* 文档类型 testCase/testReport/aiCodeReview/aiWorkLog/docUpdate
*/
private String docType;
/**
* 申诉理由
*/
private String reason;
/**
* 复核是否通过
*/
private Boolean pass;
/**
* 复核备注
*/
private String remark;
}
@@ -0,0 +1,66 @@
package com.sa.zentao.qo;
import lombok.Data;
import java.io.Serializable;
/**
* 绩效报表/评分/申诉/配置 请求参数(三期批2 /zt-perf)
*
* @author ai
* @since 2026-07-28
*/
@Data
public class ZtPerfQo implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 配置维护时为 zt_perf_config.id;申诉复核时为申诉 id(account@month#序号)
*/
private String id;
/**
* 月份 yyyy-MM
*/
private String month;
/**
* 岗位
*/
private String role;
private String account;
private String itemKey;
/**
* 人工评分分值 0~100
*/
private Double score;
private String remark;
/**
* 申诉原因
*/
private String reason;
/**
* 复核结果:upheld 维持 / overturned 撤销
*/
private String result;
private String reviewer;
/**
* 配置项字段(/zt-perf/config 维护用)
*/
private String itemName;
private Double weight;
private String ruleJson;
private Integer enabled;
}
@@ -0,0 +1,60 @@
package com.sa.zentao.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.sa.zentao.entity.ZtDocCheck;
import java.util.List;
import java.util.Map;
/**
* <p>
* 大型需求文档齐备核查 服务类(FR-014 三期)
* </p>
*
* @author ai
* @since 2026-07-28
*/
public interface IZtDocCheckService extends IService<ZtDocCheck> {
/**
* 生成某月文档齐备快照:大型需求(workload_index>20 且当月有完成度变化)× 五类文档,
* 结果写 zt_doc_check,并按 缺失数×2 合并写 zt_month_score.scopeJson 的 docCheck 节点
* @param month 考核月份 yyyy-MM
* @return 摘要 {month, storyCount, checkRows, deductByAccount}
*/
Map<String, Object> generateMonthSnapshot(String month);
/**
* 申诉:缺失项状态 pending → appealing
* @param storyId 需求ID
* @param month 月份 yyyy-MM
* @param docType 文档类型
* @param reason 申诉理由
*/
void appeal(Integer storyId, String month, String docType, String reason);
/**
* 复核:appealing → resolved;pass 时回滚该账号当月 docCheck 扣分
* @param storyId 需求ID
* @param month 月份 yyyy-MM
* @param docType 文档类型
* @param pass 复核是否通过
* @param remark 复核备注
*/
void resolveAppeal(Integer storyId, String month, String docType, boolean pass, String remark);
/**
* 某月的全部核查记录(供绩效 calculator 取数)
*/
List<ZtDocCheck> listMonthRows(String month);
/**
* 文档齐备矩阵页数据(按需求聚合:docs 五类判定/扣分/状态/申诉状态)
*/
List<Map<String, Object>> listMatrix(String month);
/**
* 月度确认:该月快照全部标记 CONFIRMED(项目经理确认后锁定)
*/
void confirmMonth(String month);
}
@@ -16,4 +16,16 @@ public interface IZtFileService extends IService<ZtFile> {
void updateFile(String fileIds, Integer objectId, FileTypes type); void updateFile(String fileIds, Integer objectId, FileTypes type);
/**
* uploadBind 绑定后按 objectType 刷新主表访问链接字段
* story→zt_story.prd_url;aiCodeReview→code_review_url+code_review_status;
* aiWorkLog→work_log_url;testCase→test_case_url;testReport→test_report_download_url;
* testReportSubmit→test_report_submit_url;testOther→test_other_url;meeting→zt_meeting.url
* @param objectType 附件类型(FileTypes.code)
* @param objectId 主表记录 id(zt_story.id / zt_meeting.id)
* @param url 附件访问链接
* @param reviewResult 审查结果 pass/reject,仅 objectType=aiCodeReview 时可选
*/
void refreshOwnerUrl(String objectType, Integer objectId, String url, String reviewResult);
} }
@@ -0,0 +1,30 @@
package com.sa.zentao.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.sa.zentao.entity.ZtPerfConfig;
/**
* <p>
* 绩效指标规则配置 服务类(三期)
* </p>
*
* @author ai
* @since 2026-07-28
*/
public interface IZtPerfConfigService extends IService<ZtPerfConfig> {
/**
* 取岗位某指标的启用配置;role 为空或无匹配时回退到该指标任意一条启用配置
* @param role 岗位编码(可为空)
* @param itemKey 指标编码
* @return 配置,无则 null
*/
ZtPerfConfig getEnabledConfig(String role, String itemKey);
/**
* 按账号解析岗位编码(依据 zt_user.userType;前后端无法区分时默认 backendDev)
* @param account 账号
* @return 岗位编码,无法识别返回 null
*/
String resolveRole(String account);
}
@@ -0,0 +1,40 @@
package com.sa.zentao.service;
import com.sa.zentao.qo.ZtPerfQo;
import com.sa.zentao.service.perf.PerfMonthScope;
/**
* <p>
* 绩效报表(三期批2:报表引擎+接口) 服务类
* </p>
*
* @author ai
* @since 2026-07-28
*/
public interface IZtPerfReportService {
/**
* 按岗位 tab 报表:已落库读 zt_month_score.scopeJson,未落库实时计算(不写库)
*/
PerfMonthScope report(String month, String role, String account);
/**
* 批量生成/重算月度得分写 zt_month_score.scopeJson;重算保留已有人工评分与申诉记录
*/
void generateMonthScore(String month, String role, String account);
/**
* 人工评分合并写 scopeJson(PRD §7.5 ❌ 项录入入口)
*/
void manualScore(ZtPerfQo qo);
/**
* 申诉登记:写 scopeJson.appeals(id 规则 account@month#序号)
*/
void appeal(ZtPerfQo qo);
/**
* 申诉复核:upheld 维持原判;overturned 撤销扣分回滚该项为满分并重算总分
*/
void appealReview(ZtPerfQo qo);
}
@@ -2,6 +2,7 @@ package com.sa.zentao.service;
import com.github.pagehelper.PageInfo; import com.github.pagehelper.PageInfo;
import com.sa.zentao.dao.ItApproval; import com.sa.zentao.dao.ItApproval;
import com.sa.zentao.dao.ZtTaskAiBatchDTO;
import com.sa.zentao.dao.ZtTaskDTO; import com.sa.zentao.dao.ZtTaskDTO;
import com.sa.zentao.entity.ZtTask; import com.sa.zentao.entity.ZtTask;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
@@ -9,6 +10,7 @@ import com.sa.zentao.qo.ZtProjectQo;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* <p> * <p>
@@ -38,6 +40,14 @@ public interface IZtTaskService extends IService<ZtTask> {
void batchAddTask(ZtTaskDTO dto); void batchAddTask(ZtTaskDTO dto);
/**
* AI 批量创建任务(事务):storyId 不存在或 type 非 devel/test 整批拒绝;
* 同 storyId+name+type 已存在跳过记 skipped;创建 zt_task(status=wait、创建人=ai、estimate=aiEvaluationTime)
* @param dto 报文 {"storyId":N,"tasks":[...]}
* @return {"created":N,"taskIds":[...],"skipped":[...]}
*/
Map<String, Object> aiBatchAdd(ZtTaskAiBatchDTO dto);
void approval(ZtTaskDTO dto); void approval(ZtTaskDTO dto);
PageInfo<ZtTaskDTO> myTaskPageList(ZtProjectQo qo); PageInfo<ZtTaskDTO> myTaskPageList(ZtProjectQo qo);
@@ -56,6 +66,12 @@ public interface IZtTaskService extends IService<ZtTask> {
List<ItApproval> itApprovalByUserName(String s, Date firstDayOfMonth, Date lastDayOfMonth); List<ItApproval> itApprovalByUserName(String s, Date firstDayOfMonth, Date lastDayOfMonth);
/**
* 按姓名批量查询请假审批(条件与 itApprovalByUserName 一致,name 改 IN;
* 供 storyBarChart 月度循环批量化,老方法保留不动)
*/
List<ItApproval> itApprovalsByNames(List<String> names, Date firstDayOfMonth, Date lastDayOfMonth);
/** /**
* *
* @param firstDayOfMonth 开始 * @param firstDayOfMonth 开始
File diff suppressed because it is too large Load Diff
@@ -526,7 +526,8 @@ public class ZtBugServiceImpl extends ServiceImpl<ZtBugMapper, ZtBug> implements
ztBugs = this.baseMapper.selectList(new QueryWrapper<ZtBug>().lambda().eq(ZtBug::getTask, task)); ztBugs = this.baseMapper.selectList(new QueryWrapper<ZtBug>().lambda().eq(ZtBug::getTask, task));
} }
if(story!=null&&story!=0){ if(story!=null&&story!=0){
ztBugs = this.baseMapper.selectList(new QueryWrapper<ZtBug>().lambda().eq(ZtBug::getStory, story)); //CHG-067:入参是 toStory 的需求ID,关联查询应打 toStory 列(story 列全库未用)
ztBugs = this.baseMapper.selectList(new QueryWrapper<ZtBug>().lambda().eq(ZtBug::getTostory, story));
} }
List<ZtBugDTO> ztBugDTOS =new ArrayList<>(); List<ZtBugDTO> ztBugDTOS =new ArrayList<>();
if(!CollectionUtils.isEmpty(ztBugs)){ if(!CollectionUtils.isEmpty(ztBugs)){
@@ -0,0 +1,464 @@
package com.sa.zentao.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.sa.zentao.dao.BusinessException;
import com.sa.zentao.entity.ZtCase;
import com.sa.zentao.entity.ZtDocCheck;
import com.sa.zentao.entity.ZtFile;
import com.sa.zentao.entity.ZtMonthScore;
import com.sa.zentao.entity.ZtStory;
import com.sa.zentao.entity.ZtStoryExpand;
import com.sa.zentao.entity.ZtStoryMonthWorkload;
import com.sa.zentao.mapper.ZtDocCheckMapper;
import com.sa.zentao.service.IZtCaseService;
import com.sa.zentao.service.IZtDocCheckService;
import com.sa.zentao.service.IZtFileService;
import com.sa.zentao.service.IZtMonthScoreService;
import com.sa.zentao.service.IZtStoryExpandService;
import com.sa.zentao.service.IZtStoryMonthWorkloadService;
import com.sa.zentao.service.IZtStoryService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* <p>
* 大型需求文档齐备核查 服务实现类(FR-014 三期)
* 大型需求 = zt_story_expand.workload_index > 20 且当月有完成度变化(zt_story_month_workload.time=month)
* 五类文档判定源:
* 《需求测试用例》=zt_case(story 关联存在);《需求测试报告》=zt_file(testReportSubmit);
* 《AI代码审查报告》=zt_file(aiCodeReview);《AI工作日志》=zt_file(aiWorkLog);
* 《AI项目文档更新记录》=zt_file(aiWorkLog) 且 extra/title 含 doc_update(宽松匹配)
* 扣分 = 缺失数×2,合并写 zt_month_score.scopeJson 的 docCheck 节点(account+dateStr 维度)
* </p>
*
* @author ai
* @since 2026-07-28
*/
@Service
@Slf4j
public class ZtDocCheckServiceImpl extends ServiceImpl<ZtDocCheckMapper, ZtDocCheck> implements IZtDocCheckService {
/** 《需求测试用例》 */
public static final String DOC_TEST_CASE = "testCase";
/** 《需求测试报告》(判定源 zt_file objectType=testReportSubmit) */
public static final String DOC_TEST_REPORT = "testReport";
/** 《AI代码审查报告》 */
public static final String DOC_AI_CODE_REVIEW = "aiCodeReview";
/** 《AI工作日志》 */
public static final String DOC_AI_WORK_LOG = "aiWorkLog";
/** 《AI项目文档更新记录》(zt_file(aiWorkLog) 中 doc_update 类内容) */
public static final String DOC_DOC_UPDATE = "docUpdate";
public static final List<String> DOC_TYPES = Arrays.asList(
DOC_TEST_CASE, DOC_TEST_REPORT, DOC_AI_CODE_REVIEW, DOC_AI_WORK_LOG, DOC_DOC_UPDATE);
/** 大型需求阈值:AI 评估工作量指数 > 20 */
public static final BigDecimal LARGE_WORKLOAD_INDEX = new BigDecimal("20");
/** 每缺失一份扣分 */
public static final int DEDUCT_PER_MISSING = 2;
/** 系统写入人 */
private static final String SYSTEM_USER = "system";
@Autowired
private IZtStoryExpandService storyExpandService;
@Autowired
private IZtStoryMonthWorkloadService storyMonthWorkloadService;
@Autowired
private IZtStoryService storyService;
@Autowired
private IZtCaseService caseService;
@Autowired
private IZtFileService fileService;
@Autowired
private IZtMonthScoreService monthScoreService;
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> generateMonthSnapshot(String month) {
if (StringUtils.isEmpty(month)) {
throw new BusinessException("月份不能为空");
}
List<Integer> storyIds = listLargeStoryIds(month);
Date now = new Date();
int checkRows = 0;
for (Integer storyId : storyIds) {
for (String docType : DOC_TYPES) {
ZtDocCheck row = checkOne(storyId, month, docType, now);
upsertRow(row);
checkRows++;
}
}
// 按责任人(zt_story.assignedTo)归集缺失扣分
Map<String, BigDecimal> deductByAccount = new HashMap<>();
if (!CollectionUtils.isEmpty(storyIds)) {
List<ZtDocCheck> rows = this.list(new QueryWrapper<ZtDocCheck>().lambda()
.eq(ZtDocCheck::getMonth, month).in(ZtDocCheck::getStoryId, storyIds));
List<ZtStory> stories = storyService.listByIds(storyIds);
Map<Integer, ZtStory> storyMap = stories.stream().collect(Collectors.toMap(ZtStory::getId, o -> o, (a, b) -> a));
Set<String> accounts = new HashSet<>();
for (ZtStory story : stories) {
if (StringUtils.isNotEmpty(story.getAssignedTo())) {
accounts.add(story.getAssignedTo());
}
}
for (String account : accounts) {
BigDecimal deduct = writeAccountDocCheckNode(account, month, rows, storyMap);
deductByAccount.put(account, deduct);
}
}
Map<String, Object> result = new HashMap<>();
result.put("month", month);
result.put("storyCount", storyIds.size());
result.put("checkRows", checkRows);
result.put("deductByAccount", deductByAccount);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void appeal(Integer storyId, String month, String docType, String reason) {
if (storyId == null || StringUtils.isEmpty(month) || StringUtils.isEmpty(docType)) {
throw new BusinessException("storyId/month/docType 不能为空");
}
if (StringUtils.isEmpty(reason)) {
throw new BusinessException("申诉理由不能为空");
}
ZtDocCheck row = getRow(storyId, month, docType);
if (row == null) {
throw new BusinessException("核查记录不存在,请先生成当月快照");
}
if (row.getPresent() != null && row.getPresent() == 1) {
throw new BusinessException("该文档已齐备,无需申诉");
}
if (!ZtDocCheck.STATUS_PENDING.equals(row.getStatus())) {
throw new BusinessException("当前状态不可申诉(仅待确认记录可申诉)");
}
row.setStatus(ZtDocCheck.STATUS_APPEALING);
row.setAppealReason(reason);
row.setUpdateTime(new Date());
this.updateById(row);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void resolveAppeal(Integer storyId, String month, String docType, boolean pass, String remark) {
if (storyId == null || StringUtils.isEmpty(month) || StringUtils.isEmpty(docType)) {
throw new BusinessException("storyId/month/docType 不能为空");
}
ZtDocCheck row = getRow(storyId, month, docType);
if (row == null) {
throw new BusinessException("核查记录不存在");
}
if (!ZtDocCheck.STATUS_APPEALING.equals(row.getStatus())) {
throw new BusinessException("仅申诉中记录可复核");
}
row.setStatus(ZtDocCheck.STATUS_RESOLVED);
row.setAppealResult(pass ? ZtDocCheck.APPEAL_PASS : ZtDocCheck.APPEAL_REJECT);
row.setResolveRemark(remark);
row.setUpdateTime(new Date());
this.updateById(row);
// 复核通过 → 回滚该账号当月 docCheck 扣分(重算责任人维度缺失)
ZtStory story = storyService.getById(storyId);
if (story != null && StringUtils.isNotEmpty(story.getAssignedTo())) {
recomputeAccountDeduction(story.getAssignedTo(), month);
}
}
@Override
public List<ZtDocCheck> listMonthRows(String month) {
if (StringUtils.isEmpty(month)) {
return new ArrayList<>();
}
return this.list(new QueryWrapper<ZtDocCheck>().lambda().eq(ZtDocCheck::getMonth, month));
}
@Override
public List<Map<String, Object>> listMatrix(String month) {
List<ZtDocCheck> rows = listMonthRows(month);
Map<Integer, List<ZtDocCheck>> byStory = rows.stream().collect(Collectors.groupingBy(ZtDocCheck::getStoryId));
List<Map<String, Object>> result = new ArrayList<>();
if (byStory.isEmpty()) {
return result;
}
List<ZtStory> stories = storyService.listByIds(byStory.keySet());
Map<Integer, String> titleMap = stories.stream()
.collect(Collectors.toMap(ZtStory::getId, ZtStory::getTitle, (a, b) -> a));
Map<Integer, String> wiMap = new HashMap<>();
for (ZtStoryExpand e : storyExpandService.list(
new QueryWrapper<ZtStoryExpand>().lambda().in(ZtStoryExpand::getStoryId, byStory.keySet()))) {
wiMap.put(e.getStoryId(), e.getWorkloadIndex());
}
for (Map.Entry<Integer, List<ZtDocCheck>> en : byStory.entrySet()) {
Map<String, Object> row = new HashMap<>();
Map<String, Integer> docs = new HashMap<>();
int missing = 0;
String status = null;
String appealStatus = null;
for (ZtDocCheck c : en.getValue()) {
int present = (c.getPresent() != null && c.getPresent() == 1) ? 1 : 0;
docs.put(c.getDocType(), present);
if (present == 0 && c.countsAsMissing()) {
missing++;
}
if (StringUtils.isNotEmpty(c.getStatus())) {
status = c.getStatus();
}
if (StringUtils.isNotEmpty(c.getAppealResult())) {
appealStatus = c.getAppealResult();
} else if (StringUtils.isNotEmpty(c.getAppealReason())) {
appealStatus = "appealing";
}
}
row.put("storyId", en.getKey());
row.put("title", titleMap.get(en.getKey()));
row.put("workloadIndex", wiMap.get(en.getKey()));
row.put("docs", docs);
row.put("deduct", missing * DEDUCT_PER_MISSING);
row.put("status", status);
row.put("appealStatus", appealStatus);
result.add(row);
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void confirmMonth(String month) {
if (StringUtils.isEmpty(month)) {
throw new BusinessException("月份不能为空");
}
List<ZtDocCheck> rows = listMonthRows(month);
for (ZtDocCheck c : rows) {
c.setStatus("CONFIRMED");
}
if (!rows.isEmpty()) {
this.updateBatchById(rows);
}
}
/**
* 大型需求 = workload_index>20 且当月有完成度变化
*/
public List<Integer> listLargeStoryIds(String month) {
List<ZtStoryMonthWorkload> monthRecords = storyMonthWorkloadService.list(
new QueryWrapper<ZtStoryMonthWorkload>().lambda().eq(ZtStoryMonthWorkload::getTime, month));
if (CollectionUtils.isEmpty(monthRecords)) {
return new ArrayList<>();
}
Set<Integer> monthStoryIds = monthRecords.stream().map(ZtStoryMonthWorkload::getStoryId).collect(Collectors.toSet());
List<ZtStoryExpand> expands = storyExpandService.list(
new QueryWrapper<ZtStoryExpand>().lambda().in(ZtStoryExpand::getStoryId, monthStoryIds));
List<Integer> storyIds = new ArrayList<>();
for (ZtStoryExpand expand : expands) {
BigDecimal workloadIndex = parseWorkloadIndex(expand.getWorkloadIndex());
if (workloadIndex != null && workloadIndex.compareTo(LARGE_WORKLOAD_INDEX) > 0) {
storyIds.add(expand.getStoryId());
}
}
return storyIds;
}
private BigDecimal parseWorkloadIndex(String workloadIndex) {
if (StringUtils.isEmpty(workloadIndex) || StringUtils.isEmpty(workloadIndex.trim())) {
return null;
}
try {
return new BigDecimal(workloadIndex.trim());
} catch (NumberFormatException e) {
return null;
}
}
/**
* 单需求单文档类型五路判定
*/
private ZtDocCheck checkOne(Integer storyId, String month, String docType, Date now) {
boolean present;
String detail;
switch (docType) {
case DOC_TEST_CASE: {
long count = caseService.count(new QueryWrapper<ZtCase>().lambda().eq(ZtCase::getStory, storyId));
present = count > 0;
detail = present ? "zt_case 关联用例 " + count + " 条" : "zt_case 无关联用例";
break;
}
case DOC_TEST_REPORT: {
long count = countFile(storyId, "testReportSubmit", false);
present = count > 0;
detail = present ? "zt_file(testReportSubmit) " + count + " 份" : "zt_file(testReportSubmit) 无附件";
break;
}
case DOC_AI_CODE_REVIEW: {
long count = countFile(storyId, "aiCodeReview", false);
present = count > 0;
detail = present ? "zt_file(aiCodeReview) " + count + " 份" : "zt_file(aiCodeReview) 无附件";
break;
}
case DOC_AI_WORK_LOG: {
long count = countFile(storyId, "aiWorkLog", false);
present = count > 0;
detail = present ? "zt_file(aiWorkLog) " + count + " 份" : "zt_file(aiWorkLog) 无附件";
break;
}
case DOC_DOC_UPDATE: {
//CHG-046 《AI项目文档更新记录》改独立通道 zt_file(aiDocUpdate)(原 aiWorkLog 的 doc_update 类从未产出,已废弃)
long count = countFile(storyId, "aiDocUpdate", false);
present = count > 0;
detail = present ? "zt_file(aiDocUpdate) " + count + " 份" : "zt_file(aiDocUpdate) 无附件";
break;
}
default:
throw new BusinessException("未知文档类型:" + docType);
}
ZtDocCheck row = new ZtDocCheck();
row.setStoryId(storyId);
row.setMonth(month);
row.setDocType(docType);
row.setPresent(present ? 1 : 0);
row.setDetail(detail);
row.setStatus(ZtDocCheck.STATUS_PENDING);
row.setCheckTime(now);
return row;
}
/**
* zt_file 附件计数(未删除;docUpdate 类要求 extra/title 含 doc_update,宽松匹配)
*/
private long countFile(Integer storyId, String objectType, boolean docUpdateOnly) {
LambdaQueryWrapper<ZtFile> wrapper = new QueryWrapper<ZtFile>().lambda()
.eq(ZtFile::getObjecttype, objectType)
.eq(ZtFile::getObjectid, storyId)
.and(w -> w.isNull(ZtFile::getDeleted).or().eq(ZtFile::getDeleted, "0"));
if (docUpdateOnly) {
wrapper.and(w -> w.like(ZtFile::getExtra, "doc_update").or().like(ZtFile::getTitle, "doc_update"));
}
return fileService.count(wrapper);
}
/**
* 按 uk_story_month_doc 覆盖写入;申诉中/已复核记录保留状态,仅刷新判定结果
*/
private void upsertRow(ZtDocCheck row) {
ZtDocCheck exist = getRow(row.getStoryId(), row.getMonth(), row.getDocType());
if (exist == null) {
row.setCreateTime(new Date());
row.setUpdateTime(new Date());
this.save(row);
return;
}
exist.setPresent(row.getPresent());
exist.setDetail(row.getDetail());
exist.setCheckTime(row.getCheckTime());
exist.setUpdateTime(new Date());
this.updateById(exist);
}
private ZtDocCheck getRow(Integer storyId, String month, String docType) {
return this.getOne(new QueryWrapper<ZtDocCheck>().lambda()
.eq(ZtDocCheck::getStoryId, storyId)
.eq(ZtDocCheck::getMonth, month)
.eq(ZtDocCheck::getDocType, docType)
.last("LIMIT 1"));
}
/**
* 重算某账号当月的 docCheck 扣分节点(申诉复核通过后回滚用)
*/
private void recomputeAccountDeduction(String account, String month) {
List<ZtDocCheck> rows = listMonthRows(month);
if (CollectionUtils.isEmpty(rows)) {
return;
}
Set<Integer> storyIds = rows.stream().map(ZtDocCheck::getStoryId).collect(Collectors.toSet());
List<ZtStory> stories = storyService.listByIds(storyIds);
Map<Integer, ZtStory> storyMap = stories.stream().collect(Collectors.toMap(ZtStory::getId, o -> o, (a, b) -> a));
writeAccountDocCheckNode(account, month, rows, storyMap);
}
/**
* 合并写 zt_month_score.scopeJson 的 docCheck 节点(account+dateStr 维度,保留已有明细)
* @return 本次缺失扣分
*/
private BigDecimal writeAccountDocCheckNode(String account, String month, List<ZtDocCheck> rows, Map<Integer, ZtStory> storyMap) {
List<ZtDocCheck> mine = rows.stream()
.filter(o -> {
ZtStory story = storyMap.get(o.getStoryId());
return story != null && account.equals(story.getAssignedTo());
})
.collect(Collectors.toList());
List<ZtDocCheck> missing = mine.stream().filter(ZtDocCheck::countsAsMissing).collect(Collectors.toList());
BigDecimal deduct = BigDecimal.valueOf((long) missing.size() * DEDUCT_PER_MISSING);
JSONObject docCheck = new JSONObject();
docCheck.put("month", month);
docCheck.put("missingCount", missing.size());
docCheck.put("deductScore", deduct);
docCheck.put("deductPerMissing", DEDUCT_PER_MISSING);
JSONArray items = new JSONArray();
for (ZtDocCheck row : missing) {
JSONObject item = new JSONObject();
item.put("storyId", row.getStoryId());
item.put("docType", row.getDocType());
item.put("detail", row.getDetail());
items.add(item);
}
docCheck.put("items", items);
ZtMonthScore monthScore = monthScoreService.getOne(new QueryWrapper<ZtMonthScore>().lambda()
.eq(ZtMonthScore::getAccount, account)
.eq(ZtMonthScore::getDateStr, month)
.last("LIMIT 1"));
Date now = new Date();
if (monthScore == null) {
monthScore = new ZtMonthScore();
monthScore.setAccount(account);
monthScore.setDateStr(month);
monthScore.setCreateDate(now);
monthScore.setCreateUser(SYSTEM_USER);
JSONObject scopeJson = new JSONObject();
scopeJson.put("docCheck", docCheck);
monthScore.setScopeJson(scopeJson.toJSONString());
monthScore.setUpdateDate(now);
monthScore.setUpdateUser(SYSTEM_USER);
monthScoreService.save(monthScore);
} else {
JSONObject scopeJson;
try {
scopeJson = StringUtils.isEmpty(monthScore.getScopeJson())
? new JSONObject() : JSON.parseObject(monthScore.getScopeJson());
} catch (Exception e) {
log.warn("zt_month_score scopeJson 非 JSON,重写 docCheck 节点 account={} month={}", account, month);
scopeJson = new JSONObject();
}
scopeJson.put("docCheck", docCheck);
monthScore.setScopeJson(scopeJson.toJSONString());
monthScore.setUpdateDate(now);
monthScore.setUpdateUser(SYSTEM_USER);
monthScoreService.updateById(monthScore);
}
return deduct;
}
}
@@ -1,13 +1,21 @@
package com.sa.zentao.service.impl; package com.sa.zentao.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.sa.zentao.conf.RiskUserThreadLocal;
import com.sa.zentao.dao.BusinessException;
import com.sa.zentao.entity.ZtFile; import com.sa.zentao.entity.ZtFile;
import com.sa.zentao.entity.ZtMeeting;
import com.sa.zentao.entity.ZtStory;
import com.sa.zentao.enums.FileTypes; import com.sa.zentao.enums.FileTypes;
import com.sa.zentao.mapper.ZtFileMapper; import com.sa.zentao.mapper.ZtFileMapper;
import com.sa.zentao.mapper.ZtMeetingMapper;
import com.sa.zentao.mapper.ZtStoryMapper;
import com.sa.zentao.service.IZtFileService; import com.sa.zentao.service.IZtFileService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -26,6 +34,18 @@ import java.util.List;
@Service @Service
public class ZtFileServiceImpl extends ServiceImpl<ZtFileMapper, ZtFile> implements IZtFileService { public class ZtFileServiceImpl extends ServiceImpl<ZtFileMapper, ZtFile> implements IZtFileService {
@Autowired
private ZtStoryMapper storyMapper;
@Autowired
private ZtMeetingMapper meetingMapper;
@Autowired
private com.sa.zentao.service.IZtActionService actionService;
@org.springframework.beans.factory.annotation.Value("${file.baseUrl}")
private String baseUrl;
@Override @Override
@Transactional @Transactional
public void updateFile(String fileIds, Integer objectId, FileTypes type) { public void updateFile(String fileIds, Integer objectId, FileTypes type) {
@@ -50,4 +70,88 @@ public class ZtFileServiceImpl extends ServiceImpl<ZtFileMapper, ZtFile> impleme
this.saveOrUpdateBatch(ztFiles); this.saveOrUpdateBatch(ztFiles);
} }
@Override
@Transactional
public void refreshOwnerUrl(String objectType, Integer objectId, String url, String reviewResult) {
if (StringUtils.isEmpty(objectType) || objectId == null) {
throw new BusinessException("objectType/objectId不能为空");
}
//动态留痕 actor 取 token 身份(无登录态兜底 ai,兼容既有单测直调)(CHG-061)
String actionActor = RiskUserThreadLocal.get() == null || StringUtils.isEmpty(RiskUserThreadLocal.get().getName())
? "ai" : RiskUserThreadLocal.get().getName();
FileTypes type = FileTypes.transferType(objectType);
if (type == null) {
throw new BusinessException("不支持的objectType:" + objectType);
}
switch (type) {
case story:
case storyPrd:
case aiCodeReview:
case aiWorkLog:
case aiDocUpdate:
case testCase:
case testReport:
case testReportSubmit:
case testOther:
ZtStory ztStory = this.storyMapper.selectById(objectId);
if (ztStory == null) {
throw new BusinessException("需求不存在:" + objectId);
}
LambdaUpdateWrapper<ZtStory> wrapper = new UpdateWrapper<ZtStory>().lambda()
.eq(ZtStory::getId, objectId);
switch (type) {
case story:
case storyPrd:
wrapper.set(ZtStory::getPrdUrl, url);
break;
case aiCodeReview:
wrapper.set(ZtStory::getCodeReviewUrl, url);
if (StringUtils.isNotEmpty(reviewResult)) {
wrapper.set(ZtStory::getCodeReviewStatus, reviewResult);
}
break;
case aiWorkLog:
wrapper.set(ZtStory::getWorkLogUrl, url);
break;
case aiDocUpdate:
wrapper.set(ZtStory::getAiDocUpdateUrl, url);
break;
case testCase:
wrapper.set(ZtStory::getTestCaseUrl, url);
break;
case testReport:
wrapper.set(ZtStory::getTestReportDownloadUrl, url);
break;
case testReportSubmit:
wrapper.set(ZtStory::getTestReportSubmitUrl, url);
break;
case testOther:
wrapper.set(ZtStory::getTestOtherUrl, url);
break;
default:
break;
}
this.storyMapper.update(null, wrapper);
//上传动态日志(zt_action):与禅道原生操作一致留痕
actionService.addAction(com.sa.zentao.enums.ActionType.XQ, com.sa.zentao.enums.ActionStatus.BJ,
objectId, ztStory.getProduct() == null ? null : ztStory.getProduct() + "", null, null,
actionActor, "上传" + type.getValue() + ":" + baseUrl + url, null);
break;
case meeting:
ZtMeeting ztMeeting = this.meetingMapper.selectById(objectId);
if (ztMeeting == null) {
throw new BusinessException("会议不存在:" + objectId);
}
this.meetingMapper.update(null, new UpdateWrapper<ZtMeeting>().lambda()
.set(ZtMeeting::getUrl, url)
.eq(ZtMeeting::getId, objectId));
actionService.addAction(com.sa.zentao.enums.ActionType.MEET, com.sa.zentao.enums.ActionStatus.BJ,
objectId, ztMeeting.getProductId() == null ? null : ztMeeting.getProductId() + "", null, null,
actionActor, "上传会议纪要:" + baseUrl + url, null);
break;
default:
throw new BusinessException("uploadBind不支持的objectType:" + objectType);
}
}
} }
@@ -20,6 +20,7 @@ import com.sa.zentao.dao.BusinessException;
import com.sa.zentao.dao.ZtMeetingDTO; import com.sa.zentao.dao.ZtMeetingDTO;
import com.sa.zentao.dao.ZtStoryDTO; import com.sa.zentao.dao.ZtStoryDTO;
import com.sa.zentao.entity.ZtMeeting; import com.sa.zentao.entity.ZtMeeting;
import com.sa.zentao.entity.ZtFile;
import com.sa.zentao.entity.ZtProduct; import com.sa.zentao.entity.ZtProduct;
import com.sa.zentao.entity.ZtStoryUser; import com.sa.zentao.entity.ZtStoryUser;
import com.sa.zentao.entity.ZtUser; import com.sa.zentao.entity.ZtUser;
@@ -157,6 +158,7 @@ public class ZtMeetingServiceImpl extends ServiceImpl<ZtMeetingMapper, ZtMeeting
this.baseMapper.insert(ztMeeting); this.baseMapper.insert(ztMeeting);
fileService.updateFile(dto.getFiles(),ztMeeting.getId(), FileTypes.meeting); fileService.updateFile(dto.getFiles(),ztMeeting.getId(), FileTypes.meeting);
refreshMeetingUrl(ztMeeting);
//添加action //添加action
@@ -165,6 +167,25 @@ public class ZtMeetingServiceImpl extends ServiceImpl<ZtMeetingMapper, ZtMeeting
} }
/**
* CHG-020:刷新会议纪要最新一份 MD 访问链接到 zt_meeting.url
* (老式两段式上传经 updateFile 绑定时同步刷新,与 uploadBind 的 refreshOwnerUrl 双通道一致)
*/
private void refreshMeetingUrl(ZtMeeting ztMeeting) {
ZtFile latest = fileService.getOne(new QueryWrapper<ZtFile>().lambda()
.eq(ZtFile::getObjecttype, FileTypes.meeting.getCode())
.eq(ZtFile::getObjectid, ztMeeting.getId())
.eq(ZtFile::getDeleted, "0")
.orderByDesc(ZtFile::getId).last("limit 1"), false);
if (latest != null) {
String url = StringUtils.isNotEmpty(latest.getUrl()) ? latest.getUrl() : latest.getPathname();
if (StringUtils.isNotEmpty(url)) {
ztMeeting.setUrl(url);
this.baseMapper.updateById(ztMeeting);
}
}
}
@Override @Override
@Transactional @Transactional
public void modify(ZtMeetingDTO dto) { public void modify(ZtMeetingDTO dto) {
@@ -182,6 +203,7 @@ public class ZtMeetingServiceImpl extends ServiceImpl<ZtMeetingMapper, ZtMeeting
this.baseMapper.updateById(ztMeeting); this.baseMapper.updateById(ztMeeting);
fileService.updateFile(dto.getFiles(),ztMeeting.getId(), FileTypes.meeting); fileService.updateFile(dto.getFiles(),ztMeeting.getId(), FileTypes.meeting);
refreshMeetingUrl(ztMeeting);
//添加action //添加action
actionService.addAction(ActionType.MEET, ActionStatus.BJ, ztMeeting.getId(), ztMeeting.getProductId() + "", null, null, actionService.addAction(ActionType.MEET, ActionStatus.BJ, ztMeeting.getId(), ztMeeting.getProductId() + "", null, null,
@@ -0,0 +1,88 @@
package com.sa.zentao.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.sa.zentao.entity.ZtPerfConfig;
import com.sa.zentao.entity.ZtUser;
import com.sa.zentao.enums.UserType;
import com.sa.zentao.mapper.ZtPerfConfigMapper;
import com.sa.zentao.service.IZtPerfConfigService;
import com.sa.zentao.service.IZtUserService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.List;
/**
* <p>
* 绩效指标规则配置 服务实现类(三期)
* </p>
*
* @author ai
* @since 2026-07-28
*/
@Service
public class ZtPerfConfigServiceImpl extends ServiceImpl<ZtPerfConfigMapper, ZtPerfConfig> implements IZtPerfConfigService {
@Autowired
private IZtUserService userService;
@Override
public ZtPerfConfig getEnabledConfig(String role, String itemKey) {
if (StringUtils.isEmpty(itemKey)) {
return null;
}
if (StringUtils.isNotEmpty(role)) {
ZtPerfConfig config = this.getOne(new QueryWrapper<ZtPerfConfig>().lambda()
.eq(ZtPerfConfig::getRole, role)
.eq(ZtPerfConfig::getItemKey, itemKey)
.eq(ZtPerfConfig::getEnabled, 1)
.last("LIMIT 1"));
if (config != null) {
return config;
}
}
// 回退:该指标任意一条启用配置(岗位未识别/未配置时兜底取规则参数)
List<ZtPerfConfig> list = this.list(new QueryWrapper<ZtPerfConfig>().lambda()
.eq(ZtPerfConfig::getItemKey, itemKey)
.eq(ZtPerfConfig::getEnabled, 1)
.last("LIMIT 1"));
return CollectionUtils.isEmpty(list) ? null : list.get(0);
}
@Override
public String resolveRole(String account) {
if (StringUtils.isEmpty(account)) {
return null;
}
ZtUser user = userService.getOne(new QueryWrapper<ZtUser>().lambda()
.eq(ZtUser::getAccount, account).last("LIMIT 1"));
if (user == null || user.getUserType() == null) {
return null;
}
UserType userType = user.getUserType();
switch (userType) {
case XMJL:
case XMGLY:
return "projectManager";
case CPJL:
return "productManager";
case CP:
case XMZL:
return "productAssistant";
case KFZ:
// 前后端经 userType 无法区分,默认后端;后续可按 zt_user.role 细分
return "backendDev";
case CS:
return "tester";
case UI:
return "uiEngineer";
case YW:
return "opsEngineer";
default:
return null;
}
}
}
@@ -0,0 +1,366 @@
package com.sa.zentao.service.impl;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.conf.RiskUserThreadLocal;
import com.sa.zentao.dao.BusinessException;
import com.sa.zentao.entity.ZtMonthScore;
import com.sa.zentao.entity.ZtPerfConfig;
import com.sa.zentao.entity.ZtUser;
import com.sa.zentao.qo.ZtPerfQo;
import com.sa.zentao.service.IZtPerfConfigService;
import com.sa.zentao.service.IZtPerfReportService;
import com.sa.zentao.service.IZtMonthScoreService;
import com.sa.zentao.service.IZtUserService;
import com.sa.zentao.service.perf.PerfIndicatorCalculator;
import com.sa.zentao.service.perf.PerfMonthScope;
import com.sa.zentao.service.perf.PerfScoreEngine;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* <p>
* 绩效报表(三期批2:报表引擎+接口) 服务实现类
* </p>
*
* @author ai
* @since 2026-07-28
*/
@Service
public class ZtPerfReportServiceImpl implements IZtPerfReportService {
private static final String APPEAL_PENDING = "pending";
private static final String APPEAL_UPHELD = "upheld";
private static final String APPEAL_OVERTURNED = "overturned";
@Autowired
private IZtMonthScoreService monthScoreService;
@Autowired
private IZtPerfConfigService perfConfigService;
@Autowired
private IZtUserService userService;
@Autowired
private PerfScoreEngine perfScoreEngine;
/**
* 批1指标计算器注册表(对接点;无计算器时 auto 项 score 为 null)
*/
@Autowired(required = false)
private List<PerfIndicatorCalculator> calculators = new ArrayList<>();
@Override
public PerfMonthScope report(String month, String role, String account) {
if (StringUtils.isBlank(account) && RiskUserThreadLocal.get() != null) {
// 未指定账号(前端“全部人员”选项 v1):默认当前登录人
account = RiskUserThreadLocal.get().getName();
}
checkMonthAccount(month, account);
if (StringUtils.isBlank(role)) {
// 岗位缺省按账号解析(批1 IZtPerfConfigService.resolveRole)
role = perfConfigService.resolveRole(account);
if (StringUtils.isBlank(role)) {
throw new BusinessException("岗位不能为空且无法按账号解析");
}
}
ZtMonthScore score = findMonthScore(account, month);
if (score != null && StringUtils.isNotBlank(score.getScopeJson())) {
return JSON.parseObject(score.getScopeJson(), PerfMonthScope.class);
}
// 未落库:实时计算(不写库)
return computeScope(account, month, role);
}
@Override
@Transactional
public void generateMonthScore(String month, String role, String account) {
if (StringUtils.isBlank(month)) {
throw new BusinessException("月份不能为空");
}
Set<String> roles = new LinkedHashSet<>();
if (StringUtils.isNotBlank(role)) {
roles.add(role);
} else if (StringUtils.isNotBlank(account)) {
// 指定账号未指定岗位:按账号解析(批1 IZtPerfConfigService.resolveRole)
String resolved = perfConfigService.resolveRole(account);
if (StringUtils.isBlank(resolved)) {
throw new BusinessException("无法按账号解析岗位:" + account);
}
roles.add(resolved);
} else {
List<ZtPerfConfig> all = perfConfigService.list(new QueryWrapper<ZtPerfConfig>().eq("enabled", 1));
for (ZtPerfConfig c : all) {
if (StringUtils.isNotBlank(c.getRole())) {
roles.add(c.getRole());
}
}
}
if (roles.isEmpty()) {
throw new BusinessException("未找到启用中的绩效规则配置");
}
List<String> accounts = new ArrayList<>();
if (StringUtils.isNotBlank(account)) {
accounts.add(account);
} else {
accounts = userService.list().stream().map(ZtUser::getAccount)
.filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList());
}
Date now = new Date();
String operator = RiskUserThreadLocal.get().getName();
for (String r : roles) {
for (String acc : accounts) {
PerfMonthScope scope = computeScope(acc, month, r);
ZtMonthScore exist = findMonthScore(acc, month);
if (exist == null) {
ZtMonthScore entity = new ZtMonthScore();
entity.setAccount(acc);
entity.setDateStr(month);
entity.setScopeJson(JSON.toJSONString(scope));
entity.setCreateDate(now);
entity.setCreateUser(operator);
entity.setUpdateDate(now);
entity.setUpdateUser(operator);
monthScoreService.save(entity);
} else {
// 重算:保留已有人工评分与申诉记录
if (StringUtils.isNotBlank(exist.getScopeJson())) {
mergeManualAndAppeals(scope, JSON.parseObject(exist.getScopeJson(), PerfMonthScope.class));
}
exist.setScopeJson(JSON.toJSONString(scope));
exist.setUpdateDate(now);
exist.setUpdateUser(operator);
monthScoreService.updateById(exist);
}
}
}
}
@Override
@Transactional
public void manualScore(ZtPerfQo qo) {
checkMonthAccount(qo.getMonth(), qo.getAccount());
if (StringUtils.isBlank(qo.getItemKey()) || qo.getScore() == null) {
throw new BusinessException("指标与分值不能为空");
}
if (qo.getScore() < 0 || qo.getScore() > 100) {
throw new BusinessException("分值需在 0~100 之间");
}
ZtMonthScore entity = requireMonthScore(qo.getAccount(), qo.getMonth());
PerfMonthScope scope = JSON.parseObject(entity.getScopeJson(), PerfMonthScope.class);
PerfMonthScope.Item item = findItem(scope, qo.getItemKey());
if (item == null) {
throw new BusinessException("指标不存在:" + qo.getItemKey());
}
item.setScore(qo.getScore());
item.setSource(PerfScoreEngine.SOURCE_MANUAL);
item.setRemark(qo.getRemark());
perfScoreEngine.recomputeTotal(scope);
saveScopeJson(entity, scope);
}
@Override
@Transactional
public void appeal(ZtPerfQo qo) {
checkMonthAccount(qo.getMonth(), qo.getAccount());
if (StringUtils.isBlank(qo.getItemKey()) || StringUtils.isBlank(qo.getReason())) {
throw new BusinessException("申诉指标与原因不能为空");
}
ZtMonthScore entity = requireMonthScore(qo.getAccount(), qo.getMonth());
PerfMonthScope scope = JSON.parseObject(entity.getScopeJson(), PerfMonthScope.class);
if (findItem(scope, qo.getItemKey()) == null) {
throw new BusinessException("指标不存在:" + qo.getItemKey());
}
if (scope.getAppeals() == null) {
scope.setAppeals(new ArrayList<>());
}
PerfMonthScope.Appeal appeal = new PerfMonthScope.Appeal();
appeal.setId(qo.getAccount() + "@" + qo.getMonth() + "#" + (scope.getAppeals().size() + 1));
appeal.setItemKey(qo.getItemKey());
appeal.setReason(qo.getReason());
appeal.setStatus(APPEAL_PENDING);
appeal.setCreateDate(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
scope.getAppeals().add(appeal);
saveScopeJson(entity, scope);
}
@Override
@Transactional
public void appealReview(ZtPerfQo qo) {
if (StringUtils.isBlank(qo.getId()) || StringUtils.isBlank(qo.getResult())) {
throw new BusinessException("申诉 id 与复核结果不能为空");
}
if (!APPEAL_UPHELD.equals(qo.getResult()) && !APPEAL_OVERTURNED.equals(qo.getResult())) {
throw new BusinessException("复核结果仅支持 upheld/overturned");
}
// 申诉 id 规则:account@month#序号
String[] parts = qo.getId().split("[@#]");
if (parts.length != 3) {
throw new BusinessException("申诉 id 格式非法:" + qo.getId());
}
ZtMonthScore entity = requireMonthScore(parts[0], parts[1]);
PerfMonthScope scope = JSON.parseObject(entity.getScopeJson(), PerfMonthScope.class);
PerfMonthScope.Appeal target = null;
if (scope.getAppeals() != null) {
for (PerfMonthScope.Appeal a : scope.getAppeals()) {
if (qo.getId().equals(a.getId())) {
target = a;
break;
}
}
}
if (target == null) {
throw new BusinessException("申诉记录不存在:" + qo.getId());
}
if (!APPEAL_PENDING.equals(target.getStatus())) {
throw new BusinessException("申诉已复核,请勿重复操作");
}
target.setStatus(qo.getResult());
target.setReviewer(qo.getReviewer());
target.setReviewDate(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
if (APPEAL_OVERTURNED.equals(qo.getResult())) {
// 撤销:回滚该项扣分为满分并重算总分
PerfMonthScope.Item item = findItem(scope, target.getItemKey());
if (item != null) {
item.setScore(100D);
item.setDeduct(0D);
item.setRemark(StringUtils.isBlank(item.getRemark()) ? "申诉撤销回滚" : item.getRemark() + ";申诉撤销回滚");
}
perfScoreEngine.recomputeTotal(scope);
}
saveScopeJson(entity, scope);
}
/**
* 实时计算:取岗位配置 + 批1指标计算器(PerfIndicatorCalculator 注册表),不调引擎外服务。
* 计算器命中时其返回值已是 0~100 得分(见其接口约定),通过 CTX_PRESCORED_ITEMS 告知引擎直接采信。
*/
private PerfMonthScope computeScope(String account, String month, String role) {
List<ZtPerfConfig> configs = perfConfigService.list(
new QueryWrapper<ZtPerfConfig>().eq("role", role).eq("enabled", 1));
if (CollectionUtils.isEmpty(configs)) {
throw new BusinessException("岗位无启用中的绩效规则配置:" + role);
}
Map<String, Double> metrics = new HashMap<>();
Map<String, String> rawDetails = new HashMap<>();
Set<String> preScored = new HashSet<>();
Map<String, PerfIndicatorCalculator> registry = new HashMap<>();
if (calculators != null) {
for (PerfIndicatorCalculator p : calculators) {
registry.put(p.key(), p);
}
}
for (ZtPerfConfig config : configs) {
PerfIndicatorCalculator calculator = registry.get(config.getItemKey());
if (calculator == null) {
continue;
}
try {
BigDecimal value = calculator.compute(account, month);
metrics.put(config.getItemKey(), value == null ? null : value.doubleValue());
preScored.add(config.getItemKey());
String detail = calculator.consumeRawDetail();
if (detail != null) {
rawDetails.put(config.getItemKey(), detail);
}
} catch (Exception e) {
// 单指标取数失败不阻塞整表:raw 置 null,该项 score 为 null
metrics.put(config.getItemKey(), null);
calculator.consumeRawDetail();
}
}
Map<String, Object> context = new HashMap<>();
context.put(PerfScoreEngine.CTX_PRESCORED_ITEMS, preScored);
PerfMonthScope scope = perfScoreEngine.compute(account, month, role, configs, metrics, context);
//CHG-041 过程值明细挂到对应指标项(弹窗「绩效数据」列展示)
if (scope.getItems() != null) {
for (PerfMonthScope.Item item : scope.getItems()) {
String detail = rawDetails.get(item.getItemKey());
if (detail != null) {
item.setRawDetail(detail);
}
}
}
return scope;
}
/**
* 重算时把旧 scope 的人工评分(source=manual 且 score 非空)与申诉记录合并进新 scope
*/
private void mergeManualAndAppeals(PerfMonthScope newScope, PerfMonthScope oldScope) {
if (oldScope.getItems() != null) {
Map<String, PerfMonthScope.Item> oldItems = oldScope.getItems().stream()
.filter(i -> i.getItemKey() != null)
.collect(Collectors.toMap(PerfMonthScope.Item::getItemKey, i -> i, (a, b) -> a));
for (PerfMonthScope.Item item : newScope.getItems()) {
PerfMonthScope.Item old = oldItems.get(item.getItemKey());
if (old != null && PerfScoreEngine.SOURCE_MANUAL.equals(old.getSource()) && old.getScore() != null) {
item.setScore(old.getScore());
item.setSource(old.getSource());
item.setRemark(old.getRemark());
}
}
}
newScope.setAppeals(oldScope.getAppeals() == null ? new ArrayList<>() : oldScope.getAppeals());
perfScoreEngine.recomputeTotal(newScope);
}
private PerfMonthScope.Item findItem(PerfMonthScope scope, String itemKey) {
if (scope.getItems() == null) {
return null;
}
for (PerfMonthScope.Item item : scope.getItems()) {
if (itemKey.equals(item.getItemKey())) {
return item;
}
}
return null;
}
private ZtMonthScore findMonthScore(String account, String month) {
List<ZtMonthScore> list = monthScoreService.list(new QueryWrapper<ZtMonthScore>()
.eq("account", account).eq("date_str", month));
return CollectionUtils.isEmpty(list) ? null : list.get(0);
}
private ZtMonthScore requireMonthScore(String account, String month) {
ZtMonthScore entity = findMonthScore(account, month);
if (entity == null || StringUtils.isBlank(entity.getScopeJson())) {
throw new BusinessException("月度得分未生成,请先调用 generateMonthScore");
}
return entity;
}
private void saveScopeJson(ZtMonthScore entity, PerfMonthScope scope) {
entity.setScopeJson(JSON.toJSONString(scope));
entity.setUpdateDate(new Date());
//无登录态(单测/未来调度线程)兜底 system,防 NPE(CHG-069)
entity.setUpdateUser(RiskUserThreadLocal.get() == null ? "system" : RiskUserThreadLocal.get().getName());
monthScoreService.updateById(entity);
}
private void checkMonthAccount(String month, String account) {
if (StringUtils.isBlank(month) || StringUtils.isBlank(account)) {
throw new BusinessException("月份与账号不能为空");
}
}
}
@@ -145,12 +145,17 @@ public class ZtProductServiceImpl extends ServiceImpl<ZtProductMapper, ZtProduct
userList.addAll(result.stream().filter(o->!StringUtils.isEmpty(o.getPo())).map(o->o.getPo()).collect(Collectors.toList())); userList.addAll(result.stream().filter(o->!StringUtils.isEmpty(o.getPo())).map(o->o.getPo()).collect(Collectors.toList()));
Map<String, ZtUser> userMap = this.userService.userMapByIds(null); Map<String, ZtUser> userMap = this.userService.userMapByIds(null);
List<ZtStory> allStoryList = this.storyService.list(new QueryWrapper<ZtStory>().lambda().in(ZtStory::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList()))); //CHG-070:产品列表统计只要计数,禁止 SELECT *——zt_bug.steps(MEDIUMTEXT,页均 44MB)等大字段拖垮接口(实测 7.3s→0.05s)
List<ZtStory> allStoryList = this.storyService.list(new QueryWrapper<ZtStory>().lambda()
.select(ZtStory::getId, ZtStory::getProduct, ZtStory::getStatus, ZtStory::getStage)
.in(ZtStory::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList())));
List<ZtBug> allBugList = bugService.list(new QueryWrapper<ZtBug>().lambda() List<ZtBug> allBugList = bugService.list(new QueryWrapper<ZtBug>().lambda()
.select(ZtBug::getId, ZtBug::getProduct, ZtBug::getStatus)
.ne(ZtBug::getStatus,"closed") .ne(ZtBug::getStatus,"closed")
.in(ZtBug::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList()))); .in(ZtBug::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList())));
List<ZtStoryUser> allUserStoryList = this.storyUserService.list(new QueryWrapper<ZtStoryUser>().lambda()
List<ZtStoryUser> allUserStoryList = this.storyUserService.list(new QueryWrapper<ZtStoryUser>().lambda().in(ZtStoryUser::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList()))); .select(ZtStoryUser::getId, ZtStoryUser::getProduct, ZtStoryUser::getStatus)
.in(ZtStoryUser::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList())));
for (ZtProductDTO p:result ) { for (ZtProductDTO p:result ) {
List<ZtStory> storyList = allStoryList.stream().filter(o->o.getProduct().equals(p.getId())).collect(Collectors.toList()); List<ZtStory> storyList = allStoryList.stream().filter(o->o.getProduct().equals(p.getId())).collect(Collectors.toList());
@@ -1337,8 +1337,10 @@ public class ZtProjectServiceImpl extends ServiceImpl<ZtProjectMapper, ZtProject
return new HashMap<>(); return new HashMap<>();
} }
//仅取预计开始在本月的任务:下方按天循环只匹配 estStarted 落在当月的任务,历史任务对结果无贡献(性能优化,口径不变)
List<ZtTask> list = this.taskService.list(new QueryWrapper<ZtTask>().lambda().select(SFunctionColums.taskColumes()) List<ZtTask> list = this.taskService.list(new QueryWrapper<ZtTask>().lambda().select(SFunctionColums.taskColumes())
.notIn(ZtTask::getStatus,"cancel","closed","reviewing","draft") .notIn(ZtTask::getStatus,"cancel","closed","reviewing","draft")
.ge(ZtTask::getEstStarted, firstDayOfMonth).le(ZtTask::getEstStarted, lastDayOfMonth)
.in(ZtTask::getExecution, execList.stream().map(o -> o.getExecution()).collect(Collectors.toList()))); .in(ZtTask::getExecution, execList.stream().map(o -> o.getExecution()).collect(Collectors.toList())));
if (CollectionUtils.isEmpty(list)) { if (CollectionUtils.isEmpty(list)) {
return new HashMap<>(); return new HashMap<>();
@@ -1369,8 +1371,16 @@ public class ZtProjectServiceImpl extends ServiceImpl<ZtProjectMapper, ZtProject
List<String> accountIds = teams.stream().map(o -> o.getAccount()).distinct().collect(Collectors.toList()); List<String> accountIds = teams.stream().map(o -> o.getAccount()).distinct().collect(Collectors.toList());
List<ZtUser> userList = this.userService.list(); //按需取用户:团队成员 ∪ 任务相关人(原 userService.list() 全表扫描;不在用户表的账号行为不变,仍为 null 跳过)
Map<String, ZtUser> uMap = userList.stream().collect(Collectors.toMap(ZtUser::getAccount, O -> O)); Set<String> userAccounts = new HashSet<>(accountIds);
for (ZtTask t : list) {
if (!StringUtils.isEmpty(t.getAssignedTo())) userAccounts.add(t.getAssignedTo());
if (!StringUtils.isEmpty(t.getOpenedby())) userAccounts.add(t.getOpenedby());
if (!StringUtils.isEmpty(t.getFinishedby())) userAccounts.add(t.getFinishedby());
if (!StringUtils.isEmpty(t.getCanceledby())) userAccounts.add(t.getCanceledby());
if (!StringUtils.isEmpty(t.getClosedby())) userAccounts.add(t.getClosedby());
}
Map<String, ZtUser> uMap = this.userService.userMapByIds(new ArrayList<>(userAccounts));
for (ZtTask t : list) { for (ZtTask t : list) {
if (t.getDeadline() != null) { if (t.getDeadline() != null) {
@@ -1398,17 +1408,16 @@ public class ZtProjectServiceImpl extends ServiceImpl<ZtProjectMapper, ZtProject
} }
} }
//预分组:yyyy-MM-dd#账号 → 任务列表(原 天数×人数×任务数 三层扫描降为 O(任务数);组内顺序与原 stream filter 一致)
Map<String, List<ZtTask>> taskByDayAccount = list.stream()
.filter(o -> o.getEstStarted() != null && !StringUtils.isEmpty(o.getAssignedTo()))
.collect(Collectors.groupingBy(o -> DateUtils.formatDate(o.getEstStarted()) + "#" + o.getAssignedTo()));
for (int i = 0; i < DateUtils.getDays(date); i++) { for (int i = 0; i < DateUtils.getDays(date); i++) {
Date d = DateUtils.dateAddDay(firstDayOfMonth, i); Date d = DateUtils.dateAddDay(firstDayOfMonth, i);
List<Map<String, Object>> l = new ArrayList<>(); List<Map<String, Object>> l = new ArrayList<>();
for (String s : accountIds) { for (String s : accountIds) {
ZtUser ztUser = uMap.get(s); ZtUser ztUser = uMap.get(s);
// List<ZtEffort> fList = efforts.stream().filter(o -> o.getAccount().equals(s)) List<ZtTask> taskList = taskByDayAccount.getOrDefault(DateUtils.formatDate(d) + "#" + s, Collections.emptyList());
// .filter(o->DateUtils.formatDate(o.getDate()).equals(DateUtils.formatDate(d)))
// .collect(Collectors.toList());
List<ZtTask> taskList = list.stream().filter(o -> o.getEstStarted() != null && DateUtils.formatDate(o.getEstStarted()).equals(DateUtils.formatDate(d)))
.filter(o -> o.getAssignedTo().equals(ztUser.getAccount()))
.collect(Collectors.toList());
for (ZtTask t:taskList ) { for (ZtTask t:taskList ) {
t.setAssignedToName(ztUser.getNickname()); t.setAssignedToName(ztUser.getNickname());
@@ -1977,6 +1986,9 @@ public class ZtProjectServiceImpl extends ServiceImpl<ZtProjectMapper, ZtProject
Map<String,ZtMonthScore> scoreMap=getAccountScoreMap(date,accountIds); Map<String,ZtMonthScore> scoreMap=getAccountScoreMap(date,accountIds);
//CHG-074:月度达标工时(团队口径人均,与绩效弹窗 myWorkScore 一致)——在窗执行 KFZ 成员 (工作天数×人数−团队请假÷8)×5÷人数
BigDecimal teamPerCapita = countService.teamExamineTime(accountIds, firstDayOfMonth, lastDayOfMonth, date);
for (String account :accountIds) { for (String account :accountIds) {
ZtUser ztUser = uMap.get(account); ZtUser ztUser = uMap.get(account);
@@ -2004,11 +2016,11 @@ public class ZtProjectServiceImpl extends ServiceImpl<ZtProjectMapper, ZtProject
dto=countService.setUserWorkTime(dto,date,approvalList,firstDayOfMonth); dto=countService.setUserWorkTime(dto,date,approvalList,firstDayOfMonth);
d.setQjTime(dto.getApprovalDays()); d.setQjTime(dto.getApprovalDays());
//可用工时 //可用工时(月度达标工时=团队口径人均,CHG-074;无 KFZ 成员时退回个人 0.75 口径兜底)
d.setHaveTime(dto.getExamineTime()); d.setHaveTime(teamPerCapita.signum() > 0 ? teamPerCapita : dto.getExamineTime());
//工作饱和度 //工作饱和度(CHG-074:分子=实绩工时 consumed、分母=团队口径达标工时,与绩效弹窗一致)
d.setSaturation(BigDecimalUtils.isZero(d.getHaveTime())?BigDecimal.valueOf(1):BigDecimalUtils.isZero(d.getWorkTime())?BigDecimal.ZERO:d.getStoryTotalTime().divide(d.getHaveTime(),2,BigDecimal.ROUND_UP)); d.setSaturation(BigDecimalUtils.isZero(d.getHaveTime())?BigDecimal.valueOf(1):BigDecimalUtils.isZero(d.getWorkTime())?BigDecimal.ZERO:d.getWorkTime().divide(d.getHaveTime(),2,BigDecimal.ROUND_UP));
// 任务总量 .filter(o->o.getStatus().equals("done")) // 任务总量 .filter(o->o.getStatus().equals("done"))
d.setTaskCount(BigDecimal.valueOf(taskList.stream().collect(Collectors.toList()).size())); d.setTaskCount(BigDecimal.valueOf(taskList.stream().collect(Collectors.toList()).size()));
@@ -2,6 +2,9 @@ package com.sa.zentao.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.sa.zentao.conf.LoginRiskUser;
import com.sa.zentao.conf.RiskUserThreadLocal;
import com.sa.zentao.dao.BusinessException;
import com.sa.zentao.entity.ZtStoryExpand; import com.sa.zentao.entity.ZtStoryExpand;
import com.sa.zentao.entity.ZtStoryMonthWorkload; import com.sa.zentao.entity.ZtStoryMonthWorkload;
import com.sa.zentao.mapper.ZtStoryExpandMapper; import com.sa.zentao.mapper.ZtStoryExpandMapper;
@@ -35,6 +38,11 @@ public class ZtStoryExpandServiceImpl extends ServiceImpl<ZtStoryExpandMapper, Z
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void saveOrUpdateExpand(ZtStoryExpand entity) { public void saveOrUpdateExpand(ZtStoryExpand entity) {
//仅 AI 框架通道可用:必须携带 ai 账户 token(CHG-061,二期必决项落地)
LoginRiskUser aiLoginUser = RiskUserThreadLocal.get();
if (aiLoginUser == null || !"ai".equals(aiLoginUser.getName())) {
throw new BusinessException("该接口仅AI框架通道可用(需ai账户token)");
}
if (entity == null || entity.getStoryId() == null) { if (entity == null || entity.getStoryId() == null) {
return; return;
} }
@@ -1979,7 +1979,8 @@ public class ZtStoryServiceImpl extends ServiceImpl<ZtStoryMapper, ZtStory> impl
} }
List<ZtBug> bugList = this.bugService.list(new QueryWrapper<ZtBug>().lambda().eq(ZtBug::getStory, i).ne(ZtBug::getStatus, "closed")); //CHG-067:需求关联 Bug 走 toStory 列(story 列全库未用)——关闭需求时联动关闭其未关闭 Bug
List<ZtBug> bugList = this.bugService.list(new QueryWrapper<ZtBug>().lambda().eq(ZtBug::getTostory, i).ne(ZtBug::getStatus, "closed"));
for (ZtBug bug : bugList) { for (ZtBug bug : bugList) {
ZtBugQo qo = new ZtBugQo(); ZtBugQo qo = new ZtBugQo();
qo.setId(bug.getId()); qo.setId(bug.getId());
@@ -392,6 +392,7 @@ public class ZtStoryUserServiceImpl extends ServiceImpl<ZtStoryUserMapper, ZtSto
d.setStoryList(ztStories.stream().map(o -> o.getId() + ":" + o.getTitle()).collect(Collectors.joining(","))); d.setStoryList(ztStories.stream().map(o -> o.getId() + ":" + o.getTitle()).collect(Collectors.joining(",")));
d.setSList(CollectionUtils.isEmpty(ztStories) ? null : BeanCopyUtil.copyListProperties(ztStories, ZtStoryDTO::new)); d.setSList(CollectionUtils.isEmpty(ztStories) ? null : BeanCopyUtil.copyListProperties(ztStories, ZtStoryDTO::new));
} }
d.setExecNames(buildExecNames(d.getExecList()));
d.setLinkUrl(url); d.setLinkUrl(url);
} }
} }
@@ -449,6 +450,24 @@ public class ZtStoryUserServiceImpl extends ServiceImpl<ZtStoryUserMapper, ZtSto
return storyToProjectsMap; return storyToProjectsMap;
} }
/**
* CHG-078:迭代版本列取值——execList 中迭代名称去重、排序后逗号拼接;空/无有效名称返回 null。
*/
static String buildExecNames(Set<ZtProject> execList) {
if (CollectionUtils.isEmpty(execList)) {
return null;
}
String names = execList.stream()
.filter(Objects::nonNull)
.map(ZtProject::getName)
.filter(StringUtils::isNotEmpty)
.distinct()
.sorted()
.collect(Collectors.joining(","));
return StringUtils.isEmpty(names) ? null : names;
}
@Override @Override
@Transactional @Transactional
public void changeStatus(ZtStoryUserDTO dto) { public void changeStatus(ZtStoryUserDTO dto) {
@@ -564,6 +583,9 @@ public class ZtStoryUserServiceImpl extends ServiceImpl<ZtStoryUserMapper, ZtSto
ztStory.setRevieweddate( ztStory.setRevieweddate(
new Date() new Date()
); );
ztStory.setActivateddate(
new Date()
);
} }
} }
if (dto.getRevieweResult() == 0) { if (dto.getRevieweResult() == 0) {
@@ -689,6 +711,7 @@ public class ZtStoryUserServiceImpl extends ServiceImpl<ZtStoryUserMapper, ZtSto
dto.setSList(storyDTOList); dto.setSList(storyDTOList);
} }
//story - exec //story - exec
dto.setExecNames(buildExecNames(dto.getExecList()));
} }
List<ZtStoryUserTask> tasks = this.storyUserTaskService.list(new QueryWrapper<ZtStoryUserTask>().lambda().eq(ZtStoryUserTask::getUserStoryId, dto.getId())); List<ZtStoryUserTask> tasks = this.storyUserTaskService.list(new QueryWrapper<ZtStoryUserTask>().lambda().eq(ZtStoryUserTask::getUserStoryId, dto.getId()));
@@ -27,6 +27,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -328,6 +330,14 @@ public class ZtTaskServiceImpl extends ServiceImpl<ZtTaskMapper, ZtTask> impleme
return this.baseMapper.itApprovalByUserName(s, firstDayOfMonth, lastDayOfMonth); return this.baseMapper.itApprovalByUserName(s, firstDayOfMonth, lastDayOfMonth);
} }
@Override
public List<ItApproval> itApprovalsByNames(List<String> names, Date firstDayOfMonth, Date lastDayOfMonth) {
if (CollectionUtils.isEmpty(names)) {
return new ArrayList<>();
}
return this.baseMapper.itApprovalsByNames(names, firstDayOfMonth, lastDayOfMonth);
}
@Override @Override
public List<ZtTask> taskListByEIdsAndDate(Date firstDayOfMonth, Date lastDayOfMonth, List<Integer> pids) { public List<ZtTask> taskListByEIdsAndDate(Date firstDayOfMonth, Date lastDayOfMonth, List<Integer> pids) {
if (CollectionUtils.isEmpty(pids)) { if (CollectionUtils.isEmpty(pids)) {
@@ -1352,6 +1362,167 @@ public class ZtTaskServiceImpl extends ServiceImpl<ZtTaskMapper, ZtTask> impleme
} }
@Override
@Transactional
public Map<String, Object> aiBatchAdd(ZtTaskAiBatchDTO dto) {
//仅 AI 框架通道可用:必须携带 ai 账户 token,创建人/留痕 actor 取自 token 身份(CHG-061)
LoginRiskUser aiLoginUser = RiskUserThreadLocal.get();
if (aiLoginUser == null || !"ai".equals(aiLoginUser.getName())) {
throw new BusinessException("aiBatchAdd仅AI框架通道可用(需ai账户token)");
}
String aiActor = aiLoginUser.getName();
if (dto.getStoryId() == null) {
throw new BusinessException("storyId不能为空");
}
ZtStory ztStory = this.storyService.getById(dto.getStoryId());
if (ztStory == null) {
throw new BusinessException("需求不存在:" + dto.getStoryId());
}
//已发布(含待验收/验收不通过:storyYs验收不通过status仍保持released)、已完成(验收通过)、已关闭的需求不允许AI拆任务
UserStoryEnums storyStatus = UserStoryEnums.transfer(ztStory.getStatus());
if (storyStatus == UserStoryEnums.RELEASED || storyStatus == UserStoryEnums.YWC || storyStatus == UserStoryEnums.CLOSED) {
throw new BusinessException("需求" + storyStatus.getValue() + ",不允许创建任务:" + dto.getStoryId());
}
List<ZtTaskAiBatchDTO.AiTaskItem> tasks = dto.getTasks();
if (CollectionUtils.isEmpty(tasks)) {
throw new BusinessException("tasks不能为空");
}
//type 非 devel/test/design/affair 整批拒绝(design CHG-081 放行、affair CHG-082 放行:均参与AI拆批;不回写需求计划时间、不进绩效专项公式)
for (ZtTaskAiBatchDTO.AiTaskItem item : tasks) {
if (StringUtils.isEmpty(item.getName())) {
throw new BusinessException("任务名称不能为空");
}
if (!TaskType.devel.getCode().equals(item.getType()) && !TaskType.test.getCode().equals(item.getType())
&& !TaskType.design.getCode().equals(item.getType()) && !TaskType.affair.getCode().equals(item.getType())) {
throw new BusinessException("任务类型仅支持devel/test/design/affair:" + item.getType());
}
}
//参数补全:推导 execution/project(老链路 batchAddTask 的看板挂载、project 回填依赖,缺失会NPE)
//需求关联多个迭代时取最后关联的(迭代id自增,project 倒序第一条即最新迭代)
ZtProjectstory execStory = this.projectstoryService.getOne(new QueryWrapper<ZtProjectstory>().lambda()
.eq(ZtProjectstory::getStory, dto.getStoryId())
.eq(ZtProjectstory::getType, ProjectTypeEnums.execution.getValue())
.orderByDesc(ZtProjectstory::getProject)
.last("limit 1"));
if (execStory == null) {
throw new BusinessException("需求未关联迭代,无法拆分任务");
}
Integer execution = execStory.getProject();
ZtProjectstory projectStoryCheck = this.projectstoryService.getOne(new QueryWrapper<ZtProjectstory>().lambda()
.eq(ZtProjectstory::getStory, dto.getStoryId())
.eq(ZtProjectstory::getType, ProjectTypeEnums.project.getValue())
.last("limit 1"));
if (projectStoryCheck == null) {
throw new BusinessException("需求未关联项目,无法拆分任务");
}
//同 storyId+name+type 已存在 → 跳过记 skipped
List<ZtTask> existList = this.list(new QueryWrapper<ZtTask>().lambda().eq(ZtTask::getStory, dto.getStoryId()));
Set<String> existKeys = existList.stream()
.filter(o -> o.getDeleted() == null || "0".equals(o.getDeleted()))
.map(o -> o.getName() + "#" + o.getType())
.collect(Collectors.toSet());
//调用前已有任务 id 快照(含已删除),用于调用后按 id 差集精确识别本次新建(openeddate 精度到秒会截断,时间比较不可靠)
Set<Integer> existIds = existList.stream().map(ZtTask::getId).collect(Collectors.toSet());
List<String> skipped = new ArrayList<>();
List<String> createdDescs = new ArrayList<>();
List<ZtTaskDTO> taskDtos = new ArrayList<>();
Set<String> createdKeys = new HashSet<>();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Date today = new Date();
for (ZtTaskAiBatchDTO.AiTaskItem item : tasks) {
String key = item.getName() + "#" + item.getType();
if (existKeys.contains(key) || createdKeys.contains(key)) {
skipped.add(item.getName());
continue;
}
//对齐老接口规则:工时必须录入
if (item.getAiEvaluationTime() == null) {
throw new BusinessException("请检查工时:" + item.getName());
}
//CHG-076:assignedTo 支持中文姓名——不是既有账号时按昵称查库映射账号(AI 框架传中文名场景)
String assignedTo = item.getAssignedTo();
if (StringUtils.isNotEmpty(assignedTo) && userService.getByAccount(assignedTo) == null) {
ZtUser byNickname = userService.getOne(new QueryWrapper<ZtUser>().lambda()
.eq(ZtUser::getNickname, assignedTo).ne(ZtUser::getDeleted, "1").last("limit 1"));
if (byNickname != null) {
assignedTo = byNickname.getAccount();
}
}
//补全老接口必传字段:estimate/left/estStarted/deadline/execution(日期缺省补当天,deadline缺省取estStarted)
ZtTaskDTO taskDto = new ZtTaskDTO();
taskDto.setStory(dto.getStoryId());
taskDto.setExecution(execution);
taskDto.setName(item.getName());
taskDto.setType(item.getType());
taskDto.setAssignedTo(assignedTo);
taskDto.setDesc(item.getDesc());
taskDto.setEstimate(item.getAiEvaluationTime());
taskDto.setLeft(item.getAiEvaluationTime());
try {
taskDto.setEstStarted(StringUtils.isNotEmpty(item.getPlanStartDate()) ? sdf.parse(item.getPlanStartDate()) : today);
taskDto.setDeadline(StringUtils.isNotEmpty(item.getDeadline()) ? sdf.parse(item.getDeadline()) : taskDto.getEstStarted());
} catch (ParseException e) {
throw new BusinessException("日期格式错误,应为yyyy-MM-dd:" + item.getName());
}
taskDtos.add(taskDto);
createdKeys.add(key);
//需求级汇总文案收集(一次批拆合并为一条需求动态,CHG-062)
String assigneeSeg = "未指派";
if (StringUtils.isNotEmpty(assignedTo)) {
ZtUser assigneeUser = userService.getByAccount(assignedTo);
String nick = assigneeUser != null && StringUtils.isNotEmpty(assigneeUser.getNickname())
? assigneeUser.getNickname() : item.getAssignedTo();
assigneeSeg = "指派:" + nick;
}
//CHG-081:类型中文名按 TaskType 枚举取值(原写死 test→测试/其他→开发,design 放行后会被误记为「开发」)
createdDescs.add(item.getName() + "(" + TaskType.transferType(item.getType()).getValue()
+ ",工时" + item.getAiEvaluationTime() + "h"
+ "," + assigneeSeg + ")");
}
//走老批量创建链路:project/product回填、需求计划时间回写、消息推送、看板挂载、任务级留痕(RW+XJ)
List<Integer> taskIds = new ArrayList<>();
if (!taskDtos.isEmpty()) {
ZtTaskDTO batchDto = new ZtTaskDTO();
batchDto.setList(taskDtos);
this.batchAddTask(batchDto);
//batchAddTask无返回值:按 id 差集 + name#type 精确匹配取本次新建任务 id
List<ZtTask> createdList = this.list(new QueryWrapper<ZtTask>().lambda()
.eq(ZtTask::getStory, dto.getStoryId()));
taskIds = createdList.stream()
.filter(o -> !existIds.contains(o.getId()))
.filter(o -> createdKeys.contains(o.getName() + "#" + o.getType()))
.map(ZtTask::getId)
.sorted()
.collect(Collectors.toList());
}
//需求级留痕:一次批拆合并写一条(CHG-062)
if (!createdDescs.isEmpty()) {
String[] nums = {"①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩"};
StringBuilder sb = new StringBuilder("AI拆分任务 ").append(createdDescs.size()).append(" 个:");
for (int i = 0; i < createdDescs.size(); i++) {
sb.append(i < 10 ? nums[i] : ("(" + (i + 1) + ")")).append(createdDescs.get(i));
if (i < createdDescs.size() - 1) {
sb.append(";");
}
}
if (!skipped.isEmpty()) {
sb.append(";重复跳过 ").append(skipped.size()).append(" 个");
}
actionService.addAction(com.sa.zentao.enums.ActionType.XQ, com.sa.zentao.enums.ActionStatus.BJ,
dto.getStoryId(), ztStory.getProduct() == null ? null : ztStory.getProduct() + "", null, null,
aiActor, sb.toString(), null);
}
Map<String, Object> result = new HashMap<>();
result.put("created", taskIds.size());
result.put("taskIds", taskIds);
result.put("skipped", skipped);
return result;
}
@Override @Override
@Transactional @Transactional
public void approval(ZtTaskDTO dto) { public void approval(ZtTaskDTO dto) {
@@ -354,6 +354,10 @@ public class ZtUserServiceImpl extends ServiceImpl<ZtUserMapper, ZtUser> impleme
} }
ZtUser ztUser = new ZtUser(); ZtUser ztUser = new ZtUser();
BeanUtils.copyProperties(user,ztUser); BeanUtils.copyProperties(user,ztUser);
//开发方向(CHG-038):仅 KFZ 保留,其余属性置空
if (ztUser.getUserType() != com.sa.zentao.enums.UserType.KFZ) {
ztUser.setDevDirection(null);
}
List<String> productList = user.getProductList(); List<String> productList = user.getProductList();
if(!CollectionUtils.isEmpty(productList)){ if(!CollectionUtils.isEmpty(productList)){
ztUser.setProductIds(StringUtils.join(productList,",")); ztUser.setProductIds(StringUtils.join(productList,","));
@@ -401,6 +405,8 @@ public class ZtUserServiceImpl extends ServiceImpl<ZtUserMapper, ZtUser> impleme
ztUser.setPassword(user.getPassword()); ztUser.setPassword(user.getPassword());
ztUser.setUserType(user.getUserType()); ztUser.setUserType(user.getUserType());
//开发方向(CHG-038):非 KFZ 置空,KFZ 按表单值
ztUser.setDevDirection(user.getUserType() == com.sa.zentao.enums.UserType.KFZ ? user.getDevDirection() : null);
ztUser.setEmail(user.getEmail()); ztUser.setEmail(user.getEmail());
ztUser.setPhone(user.getPhone()); ztUser.setPhone(user.getPhone());
if(!CollectionUtils.isEmpty(user.getProductList())){ if(!CollectionUtils.isEmpty(user.getProductList())){
@@ -0,0 +1,74 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.sa.zentao.entity.ZtDocCheck;
import com.sa.zentao.entity.ZtStory;
import com.sa.zentao.service.IZtDocCheckService;
import com.sa.zentao.service.IZtStoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 文档齐备扣分基类(FR-014 消费 zt_doc_check 快照):
* score = 100 − 缺失数×每份扣分,扣完截止;复核通过(appealResult=pass)的缺失不计。
*
* @author ai
* @since 2026-07-28
*/
public abstract class AbstractDocReadyCalculator extends AbstractPerfIndicatorCalculator {
@Autowired
protected IZtDocCheckService docCheckService;
@Autowired
protected IZtStoryService storyService;
/**
* 纳入统计的文档类型
*/
protected abstract Set<String> docTypes();
/**
* 每缺失一份扣分(缺省值,rule_json.deductPerStep 可覆盖)
*/
protected abstract int defaultDeductPerMissing();
/**
* 该核查记录是否归属此账号
*/
protected abstract boolean ownRow(ZtDocCheck row, Map<Integer, ZtStory> storyMap, String account);
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
List<ZtDocCheck> rows = docCheckService.listMonthRows(month);
if (CollectionUtils.isEmpty(rows)) {
rawDetail("当月无核查记录");
return FULL_SCORE;
}
Set<String> types = docTypes();
List<ZtDocCheck> missing = rows.stream()
.filter(o -> types.contains(o.getDocType()))
.filter(ZtDocCheck::countsAsMissing)
.collect(Collectors.toList());
if (CollectionUtils.isEmpty(missing)) {
rawDetail("缺失 0 份");
return FULL_SCORE;
}
Set<Integer> storyIds = missing.stream().map(ZtDocCheck::getStoryId).collect(Collectors.toSet());
Map<Integer, ZtStory> storyMap = storyService.listByIds(storyIds).stream()
.collect(Collectors.toMap(ZtStory::getId, o -> o, (a, b) -> a));
long mine = missing.stream().filter(o -> ownRow(o, storyMap, account)).count();
if (rule == null) {
// 无配置:返回原始值(缺失份数)
return BigDecimal.valueOf(mine);
}
int perDeduct = rule.getIntValue("deductPerStep") == 0 ? defaultDeductPerMissing() : rule.getIntValue("deductPerStep");
rawDetail("缺失 " + mine + " 份 × 扣 " + perDeduct + " 分");
return clampScore(FULL_SCORE.subtract(BigDecimal.valueOf(mine * perDeduct)));
}
}
@@ -0,0 +1,41 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import java.math.BigDecimal;
/**
* 运维周频次指标基类(监控/巡检/备份):
* 应做次数 = 当月周数 × 每周次数(timesPerWeek) × 项目数(projects,备份用);
* 缺失 = max(0, 应做 − 实做);score = 100 − 缺失×deductPerMissing,扣完截止。
*
* @author ai
* @since 2026-07-28
*/
public abstract class AbstractOpsCountCalculator extends AbstractPerfIndicatorCalculator {
/**
* 实做次数(zt_yw* 记录数,当月 + 该账号)
*/
protected abstract long actualCount(String account, String month);
protected abstract int defaultTimesPerWeek();
protected abstract int defaultDeductPerMissing();
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
long actual = actualCount(account, month);
if (rule == null) {
// 无配置:返回原始值(实做次数)
return BigDecimal.valueOf(actual);
}
int timesPerWeek = rule.getIntValue("timesPerWeek") == 0 ? defaultTimesPerWeek() : rule.getIntValue("timesPerWeek");
int projects = rule.getIntValue("projects") == 0 ? 1 : rule.getIntValue("projects");
int deductPerMissing = rule.getIntValue("deductPerMissing") == 0 ? defaultDeductPerMissing() : rule.getIntValue("deductPerMissing");
long expected = (long) weeksOfMonth(month) * timesPerWeek * projects;
long missing = Math.max(0, expected - actual);
rawDetail("实做 " + actual + " / 应做 " + expected + "(缺 " + missing + " 次)");
return clampScore(FULL_SCORE.subtract(scaleDeduct(rule, BigDecimal.valueOf(missing * deductPerMissing))));
}
}
@@ -0,0 +1,298 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.sa.zentao.entity.ZtPerfConfig;
import com.sa.zentao.service.IZtPerfConfigService;
import com.sa.zentao.utils.DateUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
/**
* 绩效指标计算器基类:配置加载 + 规则引擎(linear/threshold/manual 三类)。
* rule_json 说明见 sql/20260728_init_zt_perf_config.sql 说明头。
*
* @author ai
* @since 2026-07-28
*/
public abstract class AbstractPerfIndicatorCalculator implements PerfIndicatorCalculator {
protected static final BigDecimal FULL_SCORE = BigDecimal.valueOf(100);
/**
* CHG-041 指标过程值(分子/分母/率,如 "Σ指数314.03 / 达标105h = 299.0%"),
* 供弹窗「绩效数据」列展示;ThreadLocal 保证单例计算器并发安全,compute 消费后清除。
*/
private static final ThreadLocal<String> RAW_DETAIL = new ThreadLocal<>();
/**
* CHG-053 当前选中的产品集(program id,月报下拉):myWorkScore 入口设置,
* 各项目口径指标按该产品集过滤;null = 按本人项目(旧行为)。
*/
private static final ThreadLocal<Integer> PROGRAM = new ThreadLocal<>();
public static void setCurrentProgram(Integer program) {
if (program == null) {
PROGRAM.remove();
} else {
PROGRAM.set(program);
}
}
public static void clearCurrentProgram() {
PROGRAM.remove();
}
protected Integer currentProgram() {
return PROGRAM.get();
}
@Autowired
protected IZtPerfConfigService perfConfigService;
/**
* 子类在 doCompute 内记录过程值
*/
protected void rawDetail(String detail) {
RAW_DETAIL.set(detail);
}
@Override
public String consumeRawDetail() {
String detail = RAW_DETAIL.get();
RAW_DETAIL.remove();
return detail;
}
@Override
public BigDecimal compute(String account, String month) {
RAW_DETAIL.remove();
String role = perfConfigService.resolveRole(account);
ZtPerfConfig config = perfConfigService.getEnabledConfig(role, key());
JSONObject rule = parseRule(config);
//CHG-051 扣分尺度统一为加权尺度(用户拍板"10分满分 10-2"):
//把配置权重放进 rule,扣分按 1/权重 放大到 100 分制再扣(最终 ×权重 = xlsx 字面加权扣分)
if (rule != null && config != null && config.getWeight() != null
&& config.getWeight().compareTo(BigDecimal.ZERO) > 0) {
rule.put("__w", config.getWeight());
}
return doCompute(account, month, rule);
}
/**
* CHG-051 加权尺度扣分换算:xlsx 的"扣 X 分"是权重分值尺度(如 10 分项缺 1 份扣 2),
* 100 分制下需按 1/权重 放大;无权重配置时按原 100 分制扣分(兼容旧行为)。
*/
protected BigDecimal scaleDeduct(JSONObject rule, BigDecimal deduct) {
if (rule == null || deduct == null) {
return deduct;
}
Object w = rule.get("__w");
if (w instanceof BigDecimal && ((BigDecimal) w).compareTo(BigDecimal.ZERO) > 0) {
return deduct.divide((BigDecimal) w, 2, RoundingMode.HALF_UP);
}
return deduct;
}
/**
* 子类实现:先算原始值,再按 rule 套分;rule 为空时返回原始值
*/
protected abstract BigDecimal doCompute(String account, String month, JSONObject rule);
protected JSONObject parseRule(ZtPerfConfig config) {
if (config == null || StringUtils.isEmpty(config.getRuleJson())) {
return null;
}
try {
return JSON.parseObject(config.getRuleJson());
} catch (Exception e) {
return null;
}
}
// ==================== 规则引擎 ====================
/**
* linear:每减/增 X% 扣 Y 分,扣完截止;支持 segments 分段线性。
* 单段参数:direction=below/above、target、step、deductPerStep;
* 分段参数:segments=[{"ge":95,"from":100,"deductPer":1},...](ge 为段下界含,from 为段上界不含,扣分按 from-max(actual,ge) 计)
*
* @param actual 实际值(百分比数值,如 97 表示 97%)
* @return 得分 0~100
*/
protected BigDecimal applyLinear(JSONObject rule, BigDecimal actual) {
if (rule == null || actual == null) {
return actual == null ? BigDecimal.ZERO : actual;
}
JSONArray segments = rule.getJSONArray("segments");
BigDecimal deduct;
if (segments != null && !segments.isEmpty()) {
deduct = BigDecimal.ZERO;
for (int i = 0; i < segments.size(); i++) {
JSONObject seg = segments.getJSONObject(i);
BigDecimal ge = seg.getBigDecimal("ge");
BigDecimal from = seg.getBigDecimal("from");
BigDecimal deductPer = seg.getBigDecimal("deductPer");
if (ge == null || from == null || deductPer == null) {
continue;
}
if (actual.compareTo(from) < 0) {
BigDecimal covered = from.subtract(actual.max(ge));
if (covered.compareTo(BigDecimal.ZERO) > 0) {
deduct = deduct.add(covered.multiply(deductPer));
}
}
}
} else {
BigDecimal target = rule.getBigDecimal("target");
BigDecimal step = rule.getBigDecimal("step");
BigDecimal deductPerStep = rule.getBigDecimal("deductPerStep");
if (target == null || step == null || deductPerStep == null || step.compareTo(BigDecimal.ZERO) == 0) {
return actual;
}
String direction = rule.getString("direction");
BigDecimal diff;
if ("above".equals(direction)) {
diff = actual.subtract(target);
} else {
diff = target.subtract(actual);
}
if (diff.compareTo(BigDecimal.ZERO) <= 0) {
return FULL_SCORE;
}
BigDecimal steps = diff.divide(step, 0, RoundingMode.UP);
deduct = steps.multiply(deductPerStep);
}
return clampScore(FULL_SCORE.subtract(scaleDeduct(rule, deduct)));
}
/**
* threshold:阈值分档,bands=[{"ge":100,"score":100},{"ge":90,"score":40},{"ge":0,"score":0}],取首个 actual>=ge 档
*/
protected BigDecimal applyThreshold(JSONObject rule, BigDecimal actual) {
if (rule == null || actual == null) {
return actual == null ? BigDecimal.ZERO : actual;
}
JSONArray bands = rule.getJSONArray("bands");
if (bands == null || bands.isEmpty()) {
return actual;
}
for (int i = 0; i < bands.size(); i++) {
JSONObject band = bands.getJSONObject(i);
BigDecimal ge = band.getBigDecimal("ge");
BigDecimal score = band.getBigDecimal("score");
if (ge != null && score != null && actual.compareTo(ge) >= 0) {
return clampScore(score);
}
}
return BigDecimal.ZERO;
}
/**
* 按规则类型套分:linear/threshold;manual 或其他 → 返回原始值
*/
protected BigDecimal applyRule(JSONObject rule, BigDecimal actual) {
if (rule == null) {
return actual;
}
String type = rule.getString("type");
if ("linear".equals(type)) {
return applyLinear(rule, actual);
}
if ("threshold".equals(type)) {
return applyThreshold(rule, actual);
}
return actual;
}
protected BigDecimal clampScore(BigDecimal score) {
if (score == null) {
return BigDecimal.ZERO;
}
if (score.compareTo(BigDecimal.ZERO) < 0) {
return BigDecimal.ZERO;
}
if (score.compareTo(FULL_SCORE) > 0) {
return FULL_SCORE;
}
return score;
}
// ==================== 取数辅助 ====================
/**
* yyyy-MM → 当月第一天
*/
protected Date monthToDate(String month) {
if (StringUtils.isEmpty(month)) {
throw new IllegalArgumentException("月份不能为空");
}
String[] parts = month.split("-");
Calendar calendar = Calendar.getInstance();
calendar.clear();
calendar.set(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]) - 1, 1);
return calendar.getTime();
}
protected Date firstDayOfMonth(String month) {
return DateUtils.getFirstDayOfMonth(monthToDate(month));
}
protected Date lastDayOfMonth(String month) {
return DateUtils.getLastDayOfMonth(monthToDate(month));
}
/**
* 当月周数(按自然天数/7 向上取整,运维周频次指标基准)
*/
protected int weeksOfMonth(String month) {
Date d = monthToDate(month);
Calendar calendar = Calendar.getInstance();
calendar.setTime(d);
int days = calendar.getActualMaximum(Calendar.DAY_OF_MONTH);
return (int) Math.ceil(days / 7.0);
}
/**
* Float 集合求和(null 跳过)
*/
protected BigDecimal sumFloat(List<Float> values) {
BigDecimal sum = BigDecimal.ZERO;
if (values == null) {
return sum;
}
for (Float v : values) {
if (v != null) {
sum = sum.add(BigDecimal.valueOf(v));
}
}
return sum;
}
/**
* 百分比:分子/分母×100,两位小数;分母为 0 返回 ZERO
*/
protected BigDecimal percent(BigDecimal numerator, BigDecimal denominator) {
if (denominator == null || denominator.compareTo(BigDecimal.ZERO) == 0) {
return BigDecimal.ZERO;
}
return numerator.multiply(FULL_SCORE).divide(denominator, 2, RoundingMode.HALF_UP);
}
/**
* 千分比:分子/分母×1000,两位小数;分母为 0 返回 ZERO
*/
protected BigDecimal permille(BigDecimal numerator, BigDecimal denominator) {
if (denominator == null || denominator.compareTo(BigDecimal.ZERO) == 0) {
return BigDecimal.ZERO;
}
return numerator.multiply(BigDecimal.valueOf(1000)).divide(denominator, 2, RoundingMode.HALF_UP);
}
}
@@ -0,0 +1,55 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sa.zentao.entity.ZtTask;
import com.sa.zentao.service.IZtTaskService;
import com.sa.zentao.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
/**
* 任务及时完成率基类:Σ(按时完成任务的分配工时) ÷ Σ(当月完成任务分配工时)。
* 按时 = finishedDate ≤ deadline(当日末);typeFilter 区分全部任务(开发/UI)与测试任务(type=test)。
*
* @author ai
* @since 2026-07-28
*/
public abstract class AbstractTaskOnTimeRateCalculator extends AbstractPerfIndicatorCalculator {
@Autowired
protected IZtTaskService taskService;
/**
* 任务类型过滤,null = 全部类型
*/
protected abstract String typeFilter();
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
LambdaQueryWrapper<ZtTask> wrapper = new QueryWrapper<ZtTask>().lambda()
.eq(ZtTask::getAssignedTo, account)
.eq(ZtTask::getStatus, "done")
.ge(ZtTask::getFinishedDate, firstDayOfMonth(month))
.le(ZtTask::getFinishedDate, lastDayOfMonth(month));
if (typeFilter() != null) {
wrapper.eq(ZtTask::getType, typeFilter());
}
List<ZtTask> tasks = taskService.list(wrapper);
if (CollectionUtils.isEmpty(tasks)) {
return BigDecimal.ZERO;
}
BigDecimal totalEstimate = sumFloat(tasks.stream().map(ZtTask::getEstimate).collect(Collectors.toList()));
BigDecimal onTimeEstimate = sumFloat(tasks.stream()
.filter(o -> o.getDeadline() != null && o.getFinishedDate() != null
&& o.getFinishedDate().getTime() <= DateUtils.getDayLast(o.getDeadline()).getTime())
.map(ZtTask::getEstimate).collect(Collectors.toList()));
BigDecimal rate = percent(onTimeEstimate, totalEstimate);
return applyRule(rule, rate);
}
}
@@ -0,0 +1,120 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtBug;
import com.sa.zentao.entity.ZtStory;
import com.sa.zentao.entity.ZtTask;
import com.sa.zentao.service.IZtBugService;
import com.sa.zentao.service.IZtTaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 线上 Bug 加权扣分基类(线上Bug率/产品缺陷率,同口径)。
* 已锁口径:线上 Bug = zt_bug.bug_type=prod;severity 1 重大、2/3/4 普通(以老弹窗 getBugFindScore 为准);率按 ‰ 计。
* rule mode=perBug:score = 100 − (普通×normalDeduct + 重大×majorDeduct),扣完截止。
* rule 含 exemptPerMille(PM/产品系 4 条规则):先按 PRD §7.3 算
* Σ(当月上线需求 Bug 数) ÷ Σ(上线需求开发任务工时) ‰,≤ exemptPerMille 满分豁免,> 才按 perBug 扣分。
* CHG-050 ①分母由 zt_story.estimate 改 devel 任务 estimate 合计(用户拍板"需求工时是任务sum",estimate 全线未填);
* ②四角色(项目经理/王宇航/产品经理/产品助理)改项目口径(∩当月窗口内执行关联产品的上线需求)。
*
* @author ai
* @since 2026-07-28
*/
public abstract class AbstractWeightedBugCalculator extends AbstractWorkloadRateCalculator {
@Autowired
protected IZtBugService bugService;
@Autowired
protected IZtTaskService taskService;
@Override
protected Set<Integer> scopeStoryIds(String account, String month) {
// 不走完成率管道,钩子占位
return null;
}
@Override
protected int producerCount(String account, String month) {
return 1;
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
if (rule != null && rule.getBigDecimal("exemptPerMille") != null) {
return computeWithExempt(account, month, rule);
}
List<ZtBug> bugs = bugService.list(new QueryWrapper<ZtBug>().lambda()
.eq(ZtBug::getBugType, "prod")
.eq(ZtBug::getAssignedTo, account)
.ge(ZtBug::getOpeneddate, firstDayOfMonth(month))
.le(ZtBug::getOpeneddate, lastDayOfMonth(month)));
rawDetail("Bug " + (countMajor(bugs) + countNormal(bugs)) + " 个(重大 " + countMajor(bugs) + "/普通 " + countNormal(bugs) + "),按个扣分");
return perBugScore(bugs, rule);
}
/**
* ≤5‰ 豁免口径(PRD §7.3):分子=当月上线需求(releaseddate 当月,四角色时∩项目关联产品)的 prod Bug 数,
* 分母=当月上线需求的 devel 任务 estimate 合计(CHG-050);率 ≤ exemptPerMille 得满分,否则按个扣分。
*/
private BigDecimal computeWithExempt(String account, String month, JSONObject rule) {
List<ZtStory> stories = storyService.list(new QueryWrapper<ZtStory>().lambda()
.ge(ZtStory::getReleaseddate, firstDayOfMonth(month))
.le(ZtStory::getReleaseddate, lastDayOfMonth(month)));
boolean projectScoped = isProjectScopedRole(account);
if (projectScoped) {
Set<Integer> projectStoryIds = projectScopeStoryIds(account, month);
stories = stories.stream().filter(o -> projectStoryIds.contains(o.getId())).collect(Collectors.toList());
}
if (CollectionUtils.isEmpty(stories)) {
rawDetail("当月无上线需求" + (projectScoped ? "(项目范围)" : ""));
return FULL_SCORE;
}
List<Integer> storyIds = stories.stream().map(ZtStory::getId).collect(Collectors.toList());
BigDecimal totalEstimate = sumFloat(taskService.list(new QueryWrapper<ZtTask>().lambda()
.in(ZtTask::getStory, storyIds)
.eq(ZtTask::getType, "devel")
.eq(ZtTask::getDeleted, "0")
.notIn(ZtTask::getStatus, "cancel", "closed"))
.stream().map(ZtTask::getEstimate).collect(Collectors.toList()));
//CHG-067:zt_bug.story 为死字段(全库 prod/dev Bug 均 story=0),需求关联实际走 toStory(用户拍板"启用的是toStory")
List<ZtBug> bugs = bugService.list(new QueryWrapper<ZtBug>().lambda()
.eq(ZtBug::getBugType, "prod")
.in(ZtBug::getTostory, storyIds));
BigDecimal rate = permille(BigDecimal.valueOf(countMajor(bugs) + countNormal(bugs)), totalEstimate);
rawDetail("Bug " + (countMajor(bugs) + countNormal(bugs)) + " 个(重大 " + countMajor(bugs) + "/普通 " + countNormal(bugs) + ")/ Σ工时 " + totalEstimate.stripTrailingZeros().toPlainString() + "h = " + rate.stripTrailingZeros().toPlainString() + "‰(豁免线 " + rule.getBigDecimal("exemptPerMille") + "‰)");
if (rate.compareTo(rule.getBigDecimal("exemptPerMille")) <= 0) {
return FULL_SCORE;
}
return perBugScore(bugs, rule);
}
private BigDecimal perBugScore(List<ZtBug> bugs, JSONObject rule) {
long major = countMajor(bugs);
long normal = countNormal(bugs);
if (rule == null) {
// 无配置:返回原始值(Bug 总数)
return BigDecimal.valueOf(major + normal);
}
int normalDeduct = rule.getIntValue("normalDeduct") == 0 ? 3 : rule.getIntValue("normalDeduct");
int majorDeduct = rule.getIntValue("majorDeduct") == 0 ? 10 : rule.getIntValue("majorDeduct");
BigDecimal deduct = BigDecimal.valueOf(normal * normalDeduct + major * majorDeduct);
return clampScore(FULL_SCORE.subtract(scaleDeduct(rule, deduct)));
}
private long countMajor(List<ZtBug> bugs) {
return bugs.stream().filter(o -> o.getSeverity() != null && o.getSeverity() == 1).count();
}
private long countNormal(List<ZtBug> bugs) {
return bugs.stream().filter(o -> o.getSeverity() != null
&& (o.getSeverity() == 2 || o.getSeverity() == 3 || o.getSeverity() == 4)).count();
}
}
@@ -0,0 +1,249 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtStoryMonthWorkload;
import com.sa.zentao.entity.ZtUser;
import com.sa.zentao.enums.UserType;
import com.sa.zentao.service.IZtStoryMonthWorkloadService;
import com.sa.zentao.service.IZtUserService;
import com.sa.zentao.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 工作量指标完成率基类(FR-012)。
* 已锁口径:完成率 = Σ(zt_story_month_workload 当月 workload 增量) ÷ (可用工作天数×5×产出人数),
* workload 增量即 zt_story_month_workload.workload_index 列(写入时已按 指数×完成度增量/100 计算),
* 测试人员不计入产出方。
*
* @author ai
* @since 2026-07-28
*/
public abstract class AbstractWorkloadRateCalculator extends AbstractPerfIndicatorCalculator {
@Autowired
protected IZtStoryMonthWorkloadService storyMonthWorkloadService;
@Autowired
protected IZtUserService userService;
@Autowired
protected com.sa.zentao.service.IZtTeamService teamService;
@Autowired
protected com.sa.zentao.service.IZtProjectService projectService;
@Autowired
protected com.sa.zentao.service.IZtExecutionprojectService executionprojectService;
@Autowired
protected com.sa.zentao.service.IZtProjectproductService projectproductService;
@Autowired
protected com.sa.zentao.service.IZtProductService productService;
@Autowired
protected com.sa.zentao.service.IZtStoryService storyService;
/**
* 需求范围过滤;返回 null 表示不限制(团队口径)
*/
protected abstract Set<Integer> scopeStoryIds(String account, String month);
/**
* 产出方人数(分母 = 工作天数×5×人数;个人口径返回 1)
*/
protected abstract int producerCount(String account, String month);
/**
* 是否项目经理(含王宇航变体 projectManagerWyh)
*/
protected boolean isProjectManager(String account) {
String role = perfConfigService.resolveRole(account);
return role != null && role.startsWith("projectManager");
}
/**
* CHG-048 项目口径四角色:项目经理(含王宇航变体)、产品经理、产品助理
*/
protected boolean isProjectScopedRole(String account) {
String role = perfConfigService.resolveRole(account);
return "projectManager".equals(role) || "projectManagerWyh".equals(role)
|| "productManager".equals(role) || "productAssistant".equals(role);
}
/**
* CHG-043 项目经理项目口径:他当月窗口内(begin 或 end 落当月)参与的执行列表
*/
protected List<com.sa.zentao.entity.ZtProject> projectExecs(String account, String month) {
List<com.sa.zentao.entity.ZtTeam> teams = teamService.list(new QueryWrapper<com.sa.zentao.entity.ZtTeam>().lambda()
.eq(com.sa.zentao.entity.ZtTeam::getType, "execution")
.eq(com.sa.zentao.entity.ZtTeam::getAccount, account));
if (teams.isEmpty()) {
return Collections.emptyList();
}
Date first = firstDayOfMonth(month);
Date last = lastDayOfMonth(month);
return projectService.list(new QueryWrapper<com.sa.zentao.entity.ZtProject>().lambda()
.in(com.sa.zentao.entity.ZtProject::getId, teams.stream().map(com.sa.zentao.entity.ZtTeam::getRoot).collect(Collectors.toList())))
.stream().filter(o -> (o.getBegin() != null && !o.getBegin().before(first) && !o.getBegin().after(last))
|| (o.getEnd() != null && !o.getEnd().before(first) && !o.getEnd().after(last)))
.collect(Collectors.toList());
}
/**
* CHG-043 项目口径需求范围:他当月执行 → 关联产品的需求 id 集(无执行/无产品 → 空集,分子为 0)。
* CHG-053 优先按当前选中产品集(program)圈产品;未选中退回本人执行路径。
*/
protected Set<Integer> projectScopeStoryIds(String account, String month) {
Set<Integer> productIds = currentProgramProductIds();
if (productIds == null) {
List<com.sa.zentao.entity.ZtProject> execs = projectExecs(account, month);
if (execs.isEmpty()) {
return Collections.emptySet();
}
List<com.sa.zentao.entity.ZtExecutionproject> eps = executionprojectService.list(new QueryWrapper<com.sa.zentao.entity.ZtExecutionproject>().lambda()
.in(com.sa.zentao.entity.ZtExecutionproject::getExecution, execs.stream().map(com.sa.zentao.entity.ZtProject::getId).collect(Collectors.toList())));
if (eps.isEmpty()) {
return Collections.emptySet();
}
List<com.sa.zentao.entity.ZtProjectproduct> pps = projectproductService.list(new QueryWrapper<com.sa.zentao.entity.ZtProjectproduct>().lambda()
.in(com.sa.zentao.entity.ZtProjectproduct::getProject, eps.stream().map(com.sa.zentao.entity.ZtExecutionproject::getProject).collect(Collectors.toList())));
productIds = pps.stream().map(com.sa.zentao.entity.ZtProjectproduct::getProduct).collect(Collectors.toSet());
}
if (productIds.isEmpty()) {
return Collections.emptySet();
}
return storyService.list(new QueryWrapper<com.sa.zentao.entity.ZtStory>().lambda()
.in(com.sa.zentao.entity.ZtStory::getProduct, productIds)).stream()
.map(com.sa.zentao.entity.ZtStory::getId).collect(Collectors.toSet());
}
/**
* CHG-043 项目口径产出人数:他当月窗口内执行的 KFZ 成员数(测试不计;他本人为 XMGLY 非 KFZ,天然不含)。
* CHG-053 优先按选中产品集下在窗执行的成员数;未选中退回本人执行路径。
*/
protected int projectProducerCount(String account, String month) {
List<com.sa.zentao.entity.ZtProject> execs;
Set<Integer> productIds = currentProgramProductIds();
if (productIds != null) {
if (productIds.isEmpty()) {
return 0;
}
execs = execsOfProducts(productIds, month);
} else {
execs = projectExecs(account, month);
}
if (execs.isEmpty()) {
return 0;
}
List<com.sa.zentao.entity.ZtTeam> members = teamService.list(new QueryWrapper<com.sa.zentao.entity.ZtTeam>().lambda()
.eq(com.sa.zentao.entity.ZtTeam::getType, "execution")
.in(com.sa.zentao.entity.ZtTeam::getRoot, execs.stream().map(com.sa.zentao.entity.ZtProject::getId).collect(Collectors.toList())));
List<String> accounts = members.stream().map(com.sa.zentao.entity.ZtTeam::getAccount).distinct().collect(Collectors.toList());
if (accounts.isEmpty()) {
return 0;
}
return (int) userService.count(new QueryWrapper<ZtUser>().lambda()
.eq(ZtUser::getUserType, UserType.KFZ)
.in(ZtUser::getAccount, accounts));
}
/**
* CHG-053 当前选中产品集(program id)→ 产品 id 集;未选中(null)返回 null
*/
protected Set<Integer> currentProgramProductIds() {
Integer program = currentProgram();
if (program == null) {
return null;
}
return productService.list(new QueryWrapper<com.sa.zentao.entity.ZtProduct>().lambda()
.eq(com.sa.zentao.entity.ZtProduct::getProgram, program)).stream()
.map(com.sa.zentao.entity.ZtProduct::getId).collect(Collectors.toSet());
}
/**
* CHG-053 产品 id 集 → 当月窗口内(begin 或 end 落当月)的执行列表
*/
protected List<com.sa.zentao.entity.ZtProject> execsOfProducts(Set<Integer> productIds, String month) {
List<com.sa.zentao.entity.ZtProjectproduct> pps = projectproductService.list(new QueryWrapper<com.sa.zentao.entity.ZtProjectproduct>().lambda()
.in(com.sa.zentao.entity.ZtProjectproduct::getProduct, productIds));
if (pps.isEmpty()) {
return Collections.emptyList();
}
List<com.sa.zentao.entity.ZtExecutionproject> eps = executionprojectService.list(new QueryWrapper<com.sa.zentao.entity.ZtExecutionproject>().lambda()
.in(com.sa.zentao.entity.ZtExecutionproject::getProject, pps.stream().map(com.sa.zentao.entity.ZtProjectproduct::getProject).collect(Collectors.toList())));
if (eps.isEmpty()) {
return Collections.emptyList();
}
Date first = firstDayOfMonth(month);
Date last = lastDayOfMonth(month);
return projectService.list(new QueryWrapper<com.sa.zentao.entity.ZtProject>().lambda()
.in(com.sa.zentao.entity.ZtProject::getId, eps.stream().map(com.sa.zentao.entity.ZtExecutionproject::getExecution).collect(Collectors.toList())))
.stream().filter(o -> (o.getBegin() != null && !o.getBegin().before(first) && !o.getBegin().after(last))
|| (o.getEnd() != null && !o.getEnd().before(first) && !o.getEnd().after(last)))
.collect(Collectors.toList());
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
List<ZtStoryMonthWorkload> records = storyMonthWorkloadService.list(
new QueryWrapper<ZtStoryMonthWorkload>().lambda().eq(ZtStoryMonthWorkload::getTime, month));
Set<Integer> scope = scopeStoryIds(account, month);
BigDecimal total = BigDecimal.ZERO;
if (!CollectionUtils.isEmpty(records)) {
for (ZtStoryMonthWorkload record : records) {
if (scope != null && !scope.contains(record.getStoryId())) {
continue;
}
if (record.getWorkloadIndex() != null) {
total = total.add(record.getWorkloadIndex());
}
}
}
int workDays = DateUtils.getWorkDaysInCurrentMonth(monthToDate(month));
int producers = producerCount(account, month);
// 写死团队月达标总工时(如飞侠车服=525):选中产品集内有配置产品时,替换 工作天数×5×人数 公式算出的基数,其余逻辑不变
BigDecimal denominator;
BigDecimal fixedTeamTime = fixedTeamMonthTimeByProductIds(currentProgramProductIds());
if (fixedTeamTime != null) {
denominator = fixedTeamTime;
} else {
denominator = BigDecimal.valueOf((long) workDays * 5L * producers);
}
BigDecimal rate = percent(total, denominator);
rawDetail("Σ指数 " + total.stripTrailingZeros().toPlainString() + " / 达标 " + denominator.stripTrailingZeros().toPlainString() + "h = " + rate.stripTrailingZeros().toPlainString() + "%");
return applyRule(rule, rate);
}
/**
* 选中产品集内写死的团队月达标总工时合计(如 飞侠车服=525);未选中产品集或无配置返回 null
*/
private BigDecimal fixedTeamMonthTimeByProductIds(Set<Integer> productIds) {
if (CollectionUtils.isEmpty(productIds) || FixedTeamMonthTime.MAP.isEmpty()) {
return null;
}
List<com.sa.zentao.entity.ZtProduct> products = productService.listByIds(productIds);
BigDecimal total = BigDecimal.ZERO;
for (com.sa.zentao.entity.ZtProduct p : products) {
BigDecimal fixed = FixedTeamMonthTime.MAP.get(p.getName());
if (fixed != null) {
total = total.add(fixed);
}
}
return total.compareTo(BigDecimal.ZERO) > 0 ? total : null;
}
/**
* 开发产出人数(userType=KFZ,测试 CS 不计入产出方)
*/
protected int devProducerCount() {
long count = userService.count(new QueryWrapper<ZtUser>().lambda().eq(ZtUser::getUserType, UserType.KFZ));
return (int) count;
}
protected Set<Integer> toStoryIds(List<com.sa.zentao.entity.ZtStoryExpand> expands) {
return expands.stream().map(com.sa.zentao.entity.ZtStoryExpand::getStoryId).collect(Collectors.toSet());
}
}
@@ -0,0 +1,58 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtBug;
import com.sa.zentao.entity.ZtTask;
import com.sa.zentao.service.IZtBugService;
import com.sa.zentao.service.IZtTaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
/**
* Bug密度(后端/前端 0.3):Σ(当月完成任务Bug数) ÷ Σ(完成任务分配工时),≤15% 满分、每增1%扣3分。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class BugDensityCalculator extends AbstractPerfIndicatorCalculator {
@Autowired
private IZtTaskService taskService;
@Autowired
private IZtBugService bugService;
@Override
public String key() {
return "bugDensity";
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
List<ZtTask> tasks = taskService.list(new QueryWrapper<ZtTask>().lambda()
.eq(ZtTask::getAssignedTo, account)
.eq(ZtTask::getStatus, "done")
.ge(ZtTask::getFinishedDate, firstDayOfMonth(month))
.le(ZtTask::getFinishedDate, lastDayOfMonth(month)));
if (CollectionUtils.isEmpty(tasks)) {
return BigDecimal.ZERO;
}
List<Integer> taskIds = tasks.stream().map(ZtTask::getId).collect(Collectors.toList());
long bugCount = bugService.count(new QueryWrapper<ZtBug>().lambda().in(ZtBug::getTask, taskIds));
BigDecimal estimateSum = sumFloat(tasks.stream().map(ZtTask::getEstimate).collect(Collectors.toList()));
BigDecimal density;
if (estimateSum.compareTo(BigDecimal.ZERO) == 0) {
// 除零边界:有 Bug 视为 100%,无 Bug 为 0
density = bugCount > 0 ? FULL_SCORE : BigDecimal.ZERO;
} else {
density = percent(BigDecimal.valueOf(bugCount), estimateSum);
}
return applyRule(rule, density);
}
}
@@ -0,0 +1,116 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtFile;
import com.sa.zentao.entity.ZtStory;
import com.sa.zentao.service.IZtFileService;
import com.sa.zentao.service.IZtStoryService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
/**
* 代码质量(后端/前端 0.1):解析 zt_file(objectType=aiCodeReview) MD 头部「严重:N/错误:N」计数扣分。
* rule mode=round(后端,默认):初审(round≤1) 严重1处扣3分、错误超6处扣3分;复审(round≥2) 严重1处扣5分、错误1处扣1分;
* rule mode=flat(前端):每发现1问题扣3分(severeDeduct/errorDeduct 可配)。
* 轮次取 zt_file.extra {"round":N},缺省按初审。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class CodeQualityCalculator extends AbstractPerfIndicatorCalculator {
@Autowired
private IZtStoryService storyService;
@Autowired
private IZtFileService fileService;
@Autowired
private MdContentResolver mdContentResolver;
@Override
public String key() {
return "codeQuality";
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
List<ZtStory> stories = storyService.list(new QueryWrapper<ZtStory>().lambda()
.eq(ZtStory::getAssignedTo, account));
if (CollectionUtils.isEmpty(stories)) {
// 无负责需求:无审查对象,不扣分
return FULL_SCORE;
}
List<ZtFile> files = fileService.list(new QueryWrapper<ZtFile>().lambda()
.eq(ZtFile::getObjecttype, "aiCodeReview")
.in(ZtFile::getObjectid, stories.stream().map(ZtStory::getId).collect(Collectors.toList()))
.and(w -> w.isNull(ZtFile::getDeleted).or().eq(ZtFile::getDeleted, "0"))
.ge(ZtFile::getAddeddate, firstDayOfMonth(month))
.le(ZtFile::getAddeddate, lastDayOfMonth(month)));
if (CollectionUtils.isEmpty(files)) {
return FULL_SCORE;
}
String mode = rule == null ? "round" : rule.getString("mode");
BigDecimal deduct = BigDecimal.ZERO;
int totalProblems = 0;
for (ZtFile file : files) {
String content = mdContentResolver.readContent(file);
int[] counts = MdContentResolver.parseReviewCounts(content);
totalProblems += counts[0] + counts[1];
if ("flat".equals(mode)) {
int severeDeduct = rule.getIntValue("severeDeduct") == 0 ? 3 : rule.getIntValue("severeDeduct");
int errorDeduct = rule.getIntValue("errorDeduct") == 0 ? 3 : rule.getIntValue("errorDeduct");
deduct = deduct.add(BigDecimal.valueOf((long) counts[0] * severeDeduct + (long) counts[1] * errorDeduct));
} else {
deduct = deduct.add(roundDeduct(rule, parseRound(file), counts));
}
}
if (rule == null) {
// 无配置:返回原始值(问题总数)
return BigDecimal.valueOf(totalProblems);
}
return clampScore(FULL_SCORE.subtract(deduct));
}
/**
* 后端轮次扣分:初审严重1处扣3、错误超6处扣3;复审严重1处扣5、错误1处扣1
*/
private BigDecimal roundDeduct(JSONObject rule, int round, int[] counts) {
if (round >= 2) {
int severeDeduct = rule.getIntValue("reRoundSevereDeduct") == 0 ? 5 : rule.getIntValue("reRoundSevereDeduct");
int errorDeduct = rule.getIntValue("reRoundErrorDeduct") == 0 ? 1 : rule.getIntValue("reRoundErrorDeduct");
return BigDecimal.valueOf((long) counts[0] * severeDeduct + (long) counts[1] * errorDeduct);
}
int severeDeduct = rule.getIntValue("firstRoundSevereDeduct") == 0 ? 3 : rule.getIntValue("firstRoundSevereDeduct");
int threshold = rule.getIntValue("firstRoundErrorThreshold") == 0 ? 6 : rule.getIntValue("firstRoundErrorThreshold");
int errorDeduct = rule.getIntValue("firstRoundErrorDeduct") == 0 ? 3 : rule.getIntValue("firstRoundErrorDeduct");
long deduct = (long) counts[0] * severeDeduct;
if (counts[1] > threshold) {
deduct += errorDeduct;
}
return BigDecimal.valueOf(deduct);
}
/**
* 审查轮次:zt_file.extra {"round":N},缺省 1(初审)
*/
private int parseRound(ZtFile file) {
if (StringUtils.isEmpty(file.getExtra())) {
return 1;
}
try {
JSONObject extra = JSON.parseObject(file.getExtra());
Integer round = extra.getInteger("round");
return round == null || round < 1 ? 1 : round;
} catch (Exception e) {
return 1;
}
}
}
@@ -0,0 +1,65 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtBug;
import com.sa.zentao.entity.ZtTask;
import com.sa.zentao.service.IZtBugService;
import com.sa.zentao.service.IZtTaskService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
/**
* 缺陷检出率(测试工程师 0.3):(普通Bug×1+重大Bug×5) ÷ 测试需求开发工时,>20% 满分、每减1%扣2分。
* 已锁口径:severity 1 重大、2/3/4 普通(以老弹窗 getBugFindScore 为准)。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class DefectFindRateCalculator extends AbstractPerfIndicatorCalculator {
@Autowired
private IZtBugService bugService;
@Autowired
private IZtTaskService taskService;
@Override
public String key() {
return "defectFindRate";
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
List<ZtBug> bugs = bugService.list(new QueryWrapper<ZtBug>().lambda()
.eq(ZtBug::getOpenedby, account)
.ge(ZtBug::getOpeneddate, firstDayOfMonth(month))
.le(ZtBug::getOpeneddate, lastDayOfMonth(month)));
// 与 IZtCountService.getBugFindScore 同口径:closed/cancel 且无解决人 不计
bugs = bugs.stream()
.filter(o -> ("closed".equals(o.getStatus()) || "cancel".equals(o.getStatus()))
? StringUtils.isNotEmpty(o.getResolvedby()) : true)
.collect(Collectors.toList());
long major = bugs.stream().filter(o -> o.getSeverity() != null && o.getSeverity() == 1).count();
long normal = bugs.stream().filter(o -> o.getSeverity() != null
&& (o.getSeverity() == 2 || o.getSeverity() == 3 || o.getSeverity() == 4)).count();
long weighted = major * 5 + normal;
List<Integer> storyIds = bugs.stream().map(ZtBug::getTostory).filter(o -> o != null).distinct().collect(Collectors.toList());
BigDecimal devHours = BigDecimal.ZERO;
if (!CollectionUtils.isEmpty(storyIds)) {
List<ZtTask> devTasks = taskService.list(new QueryWrapper<ZtTask>().lambda()
.eq(ZtTask::getType, "devel")
.notIn(ZtTask::getStatus, "cancel", "closed")
.in(ZtTask::getStory, storyIds));
devHours = sumFloat(devTasks.stream().map(ZtTask::getEstimate).collect(Collectors.toList()));
}
BigDecimal rate = percent(BigDecimal.valueOf(weighted), devHours);
return applyRule(rule, rate);
}
}
@@ -0,0 +1,123 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtStory;
import com.sa.zentao.entity.ZtStoryExpand;
import com.sa.zentao.entity.ZtStoryMonthWorkload;
import com.sa.zentao.service.IZtStoryExpandService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 文档齐备(大型需求五类,项目经理 0.1):每缺失一份扣2分,扣完截止。
* CHG-047 改实时判定(用户拍板):直接读 zt_story 五个 url 字段非空即在,
* 不再依赖 zt_doc_check 快照/月末 job;归属改项目口径(项目经理=他当月窗口内执行关联产品的大型需求)。
* 大型需求 = 当月有工作量指数记录且 AI 评估指数 >20(与原快照范围一致)。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class DocReadyScoreCalculator extends AbstractWorkloadRateCalculator {
@Autowired
private IZtStoryExpandService storyExpandService;
@Override
public String key() {
return "docReadyScore";
}
@Override
protected Set<Integer> scopeStoryIds(String account, String month) {
// 本计算器不走完成率管道(doCompute 整体重写),钩子占位
return null;
}
@Override
protected int producerCount(String account, String month) {
return 1;
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
// 大型需求:当月有指数记录且指数>20
List<ZtStoryMonthWorkload> records = storyMonthWorkloadService.list(
new QueryWrapper<ZtStoryMonthWorkload>().lambda().eq(ZtStoryMonthWorkload::getTime, month));
if (CollectionUtils.isEmpty(records)) {
rawDetail("当月无大型需求");
return FULL_SCORE;
}
Set<Integer> monthStoryIds = records.stream().map(ZtStoryMonthWorkload::getStoryId).collect(Collectors.toSet());
Set<Integer> largeIds = storyExpandService.list(new QueryWrapper<ZtStoryExpand>().lambda()
.in(ZtStoryExpand::getStoryId, monthStoryIds)).stream()
.filter(o -> parseIndex(o.getWorkloadIndex()).compareTo(BigDecimal.valueOf(20)) > 0)
.map(ZtStoryExpand::getStoryId).collect(Collectors.toSet());
if (largeIds.isEmpty()) {
rawDetail("当月无大型需求");
return FULL_SCORE;
}
// 项目经理归属:∩ 他当月窗口内执行关联产品的需求(CHG-043 项目口径)
Set<Integer> scopeIds = largeIds;
if (isProjectManager(account)) {
Set<Integer> projectStoryIds = projectScopeStoryIds(account, month);
scopeIds = largeIds.stream().filter(projectStoryIds::contains).collect(Collectors.toSet());
if (scopeIds.isEmpty()) {
rawDetail("当月无大型需求(项目范围)");
return FULL_SCORE;
}
}
Map<Integer, ZtStory> storyMap = storyService.listByIds(scopeIds).stream()
.collect(Collectors.toMap(ZtStory::getId, o -> o, (a, b) -> a));
// 五类 url 字段非空即在:用例/报告提交/更新记录/审查/日志
int missing = 0;
StringBuilder missDesc = new StringBuilder();
for (ZtStory s : storyMap.values()) {
List<String> lack = new ArrayList<>();
if (StringUtils.isBlank(s.getTestCaseUrl())) {
lack.add("用例");
}
if (StringUtils.isBlank(s.getTestReportSubmitUrl())) {
lack.add("报告");
}
if (StringUtils.isBlank(s.getAiDocUpdateUrl())) {
lack.add("更新记录");
}
if (StringUtils.isBlank(s.getCodeReviewUrl())) {
lack.add("审查");
}
if (StringUtils.isBlank(s.getWorkLogUrl())) {
lack.add("日志");
}
missing += lack.size();
if (!lack.isEmpty()) {
missDesc.append(s.getId()).append("缺[").append(String.join(",", lack)).append("] ");
}
}
int perDeduct = rule == null || rule.getIntValue("deductPerStep") == 0 ? 2 : rule.getIntValue("deductPerStep");
rawDetail("大型需求 " + scopeIds.size() + " 个,缺失 " + missing + " 份"
+ (missing > 0 ? "(" + missDesc.toString().trim() + ")" : ""));
return clampScore(FULL_SCORE.subtract(scaleDeduct(rule, BigDecimal.valueOf((long) missing * perDeduct))));
}
private BigDecimal parseIndex(String workloadIndex) {
if (workloadIndex == null || workloadIndex.trim().isEmpty()) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(workloadIndex.trim());
} catch (NumberFormatException e) {
return BigDecimal.ZERO;
}
}
}
@@ -0,0 +1,26 @@
package com.sa.zentao.service.perf;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* 团队月度达标总工时写死配置:产品名 → 团队月达标总工时。
* 命中产品的团队,达标基数用写死值替换公式算出的值(工作天数×人数×5),各公式其余逻辑不变。
* 以后别的产品要写死,往 MAP 加一行。
*/
public final class FixedTeamMonthTime {
public static final Map<String, BigDecimal> MAP;
static {
Map<String, BigDecimal> m = new HashMap<>();
// 飞侠车服(外包团队合同包月工时)
m.put("飞侠车服", BigDecimal.valueOf(525));
MAP = Collections.unmodifiableMap(m);
}
private FixedTeamMonthTime() {
}
}
@@ -0,0 +1,125 @@
package com.sa.zentao.service.perf;
import com.sa.zentao.entity.ZtFile;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* MD 附件内容读取器(三期:代码审查报告头部「严重:N/错误:N」解析)。
* 取数顺序:url/pathname 为相对路径(/zentao/img/xxx.md)→ 读本地文件目录;
* 绝对 http 地址 → URL 拉取(与 CommonsController.downloadByIO 同通道)。
*
* @author ai
* @since 2026-07-28
*/
@Component
@Slf4j
public class MdContentResolver {
/** 严重计数(支持全/半角冒号) */
private static final Pattern SEVERE_PATTERN = Pattern.compile("严重\\s*[::]\\s*(\\d+)");
/** 错误计数 */
private static final Pattern ERROR_PATTERN = Pattern.compile("错误\\s*[::]\\s*(\\d+)");
/** 头部扫描行数上限 */
private static final int HEADER_LINES = 30;
@Value("${file.path.windows:}")
private String windowsFilePath;
@Value("${file.path.linux:}")
private String linuxFilePath;
/**
* 读取附件文本内容,失败返回 null(不抛异常,取数降级由调用方决定)
*/
public String readContent(ZtFile ztFile) {
if (ztFile == null) {
return null;
}
String path = StringUtils.isNotEmpty(ztFile.getUrl()) ? ztFile.getUrl() : ztFile.getPathname();
if (StringUtils.isEmpty(path)) {
return null;
}
try {
if (path.startsWith("http://") || path.startsWith("https://")) {
return readByUrl(path);
}
return readLocal(path);
} catch (Exception e) {
log.warn("读取附件内容失败 id={} path={} err={}", ztFile.getId(), path, e.getMessage());
return null;
}
}
private String readLocal(String path) throws Exception {
String fileName = path.substring(path.lastIndexOf("/") + 1);
String dir;
String os = System.getProperty("os.name");
if (os != null && os.toLowerCase().startsWith("win")) {
dir = windowsFilePath;
} else {
dir = linuxFilePath;
}
if (StringUtils.isEmpty(dir)) {
return null;
}
File file = new File(dir, fileName);
if (!file.exists()) {
return null;
}
byte[] bytes = Files.readAllBytes(file.toPath());
return new String(bytes, StandardCharsets.UTF_8);
}
private String readByUrl(String url) throws Exception {
try (InputStream is = new URL(url).openStream();
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buff = new byte[8192];
int count;
while ((count = is.read(buff)) != -1) {
bos.write(buff, 0, count);
}
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
}
}
/**
* 解析审查报告 MD 头部「严重:N/错误:N」计数(仅扫描前 {@value #HEADER_LINES} 行)。
*
* @param content MD 内容(可空)
* @return int[]{严重数, 错误数}
*/
public static int[] parseReviewCounts(String content) {
int severe = 0;
int error = 0;
if (StringUtils.isEmpty(content)) {
return new int[]{0, 0};
}
String[] lines = content.split("\\R");
StringBuilder header = new StringBuilder();
int limit = Math.min(lines.length, HEADER_LINES);
for (int i = 0; i < limit; i++) {
header.append(lines[i]).append('\n');
}
Matcher severeMatcher = SEVERE_PATTERN.matcher(header.toString());
if (severeMatcher.find()) {
severe = Integer.parseInt(severeMatcher.group(1));
}
Matcher errorMatcher = ERROR_PATTERN.matcher(header.toString());
if (errorMatcher.find()) {
error = Integer.parseInt(errorMatcher.group(1));
}
return new int[]{severe, error};
}
}
@@ -0,0 +1,18 @@
package com.sa.zentao.service.perf;
import org.springframework.stereotype.Component;
/**
* 线上Bug(项目经理 0.1:普通扣3/重大扣10;测试工程师 0.2:普通扣5/重大该项0分——规则参数在 zt_perf_config)。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class OnlineBugRateCalculator extends AbstractWeightedBugCalculator {
@Override
public String key() {
return "onlineBugRate";
}
}
@@ -0,0 +1,44 @@
package com.sa.zentao.service.perf;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtYwBackups;
import com.sa.zentao.service.IZtYwBackupsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 数据库备份(运维 0.1):每项目每周全量,缺一个扣3分;实做 = zt_yw_backups 当月记录数;
* 项目数经 rule_json.projects 配置(缺省 1)。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class OpsBackupCalculator extends AbstractOpsCountCalculator {
@Autowired
private IZtYwBackupsService backupsService;
@Override
public String key() {
return "opsBackup";
}
@Override
protected long actualCount(String account, String month) {
return backupsService.count(new QueryWrapper<ZtYwBackups>().lambda()
.eq(ZtYwBackups::getCreateUser, account)
.ge(ZtYwBackups::getBackupsDate, firstDayOfMonth(month))
.le(ZtYwBackups::getBackupsDate, lastDayOfMonth(month)));
}
@Override
protected int defaultTimesPerWeek() {
return 1;
}
@Override
protected int defaultDeductPerMissing() {
return 3;
}
}
@@ -0,0 +1,43 @@
package com.sa.zentao.service.perf;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtYwPatrol;
import com.sa.zentao.service.IZtYwPatrolService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 职场巡检(运维 0.1):每周1次,缺一次扣5分;实做 = zt_yw_patrol 当月记录数。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class OpsInspectCalculator extends AbstractOpsCountCalculator {
@Autowired
private IZtYwPatrolService patrolService;
@Override
public String key() {
return "opsInspect";
}
@Override
protected long actualCount(String account, String month) {
return patrolService.count(new QueryWrapper<ZtYwPatrol>().lambda()
.eq(ZtYwPatrol::getPatrolUser, account)
.ge(ZtYwPatrol::getPatrolDate, firstDayOfMonth(month))
.le(ZtYwPatrol::getPatrolDate, lastDayOfMonth(month)));
}
@Override
protected int defaultTimesPerWeek() {
return 1;
}
@Override
protected int defaultDeductPerMissing() {
return 5;
}
}
@@ -0,0 +1,63 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtUser;
import com.sa.zentao.entity.ZtYwTask;
import com.sa.zentao.service.IZtUserService;
import com.sa.zentao.service.IZtYwTaskService;
import com.sa.zentao.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
/**
* 运维大项任务及时完成(运维 0.2):当月到期运维任务 及时完成率×20(折成百分制得分=及时率×100)。
* 及时 = finishDate ≤ taskEndDate(当日末)。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class OpsMajorTaskCalculator extends AbstractPerfIndicatorCalculator {
@Autowired
private IZtYwTaskService ywTaskService;
@Autowired
private IZtUserService userService;
@Override
public String key() {
return "opsMajorTask";
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
//CHG-039 同类修复:zt_yw_task.belong_to_user 实际存中文姓名(“岑海峰”),按 account 匹配恒空 → 按昵称匹配,account 兜底
String nameKey = account;
ZtUser u = userService.getByAccount(account);
if (u != null && u.getNickname() != null && !u.getNickname().isEmpty()) {
nameKey = u.getNickname();
}
final String personKey = nameKey;
List<ZtYwTask> tasks = ywTaskService.list(new QueryWrapper<ZtYwTask>().lambda()
.and(w -> w.eq(ZtYwTask::getBelongToUser, personKey).or().eq(ZtYwTask::getBelongToUser, account))
.ge(ZtYwTask::getTaskEndDate, firstDayOfMonth(month))
.le(ZtYwTask::getTaskEndDate, lastDayOfMonth(month)));
if (CollectionUtils.isEmpty(tasks)) {
rawDetail("当月无到期运维任务");
return BigDecimal.ZERO;
}
long onTime = tasks.stream()
.filter(o -> o.getFinishDate() != null
&& o.getFinishDate().getTime() <= DateUtils.getDayLast(o.getTaskEndDate()).getTime())
.count();
BigDecimal rate = percent(BigDecimal.valueOf(onTime), BigDecimal.valueOf(tasks.size()));
rawDetail("及时 " + onTime + " / 到期 " + tasks.size() + " = " + rate.stripTrailingZeros().toPlainString() + "%");
return applyRule(rule, rate);
}
}
@@ -0,0 +1,43 @@
package com.sa.zentao.service.perf;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtYwFwqsearch;
import com.sa.zentao.service.IZtYwFwqsearchService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 系统运维监控(运维 0.15):每周2次,缺一次扣3分;实做 = zt_yw_fwqsearch 当月记录数。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class OpsMonitorCalculator extends AbstractOpsCountCalculator {
@Autowired
private IZtYwFwqsearchService fwqsearchService;
@Override
public String key() {
return "opsMonitor";
}
@Override
protected long actualCount(String account, String month) {
return fwqsearchService.count(new QueryWrapper<ZtYwFwqsearch>().lambda()
.eq(ZtYwFwqsearch::getCreateUser, account)
.ge(ZtYwFwqsearch::getCreateDate, firstDayOfMonth(month))
.le(ZtYwFwqsearch::getCreateDate, lastDayOfMonth(month)));
}
@Override
protected int defaultTimesPerWeek() {
return 2;
}
@Override
protected int defaultDeductPerMissing() {
return 3;
}
}
@@ -0,0 +1,37 @@
package com.sa.zentao.service.perf;
import java.math.BigDecimal;
/**
* 绩效指标计算器(三期,策略模式)。
* 每个实现对应 zt_perf_config.item_key 一个指标;规则参数从 zt_perf_config.rule_json 读取。
*
* @author ai
* @since 2026-07-28
*/
public interface PerfIndicatorCalculator {
/**
* 指标编码,与 zt_perf_config.item_key 对应
*/
String key();
/**
* 计算某账号某月的指标值。
* 命中启用配置时返回该项得分(0~100,项内扣分扣完截止);
* 无配置/规则类型为 manual 以外的兜底场景返回原始值(如比率%、工时、次数)。
*
* @param account 账号(zt_user.account)
* @param month 月份 yyyy-MM
* @return 得分或原始值,永不返回 null
*/
BigDecimal compute(String account, String month);
/**
* CHG-041 消费并清除上一次 compute 的过程值明细(分子/分母/率),无则返回 null。
* 默认实现返回 null(非 AbstractPerfIndicatorCalculator 体系不受影响)。
*/
default String consumeRawDetail() {
return null;
}
}
@@ -0,0 +1,101 @@
package com.sa.zentao.service.perf;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* zt_month_score.scopeJson 统一结构(三期批2 约定,引擎与接口共用):
* {"month":"yyyy-MM","role":"backend","account":"zhangsan","total":92.5,
* "items":[{"itemKey":"bugDensity","weight":0.3,"raw":0.12,"score":82,"source":"auto"}],
* "bonus":[...],"appeals":[...]}
*
* @author ai
* @since 2026-07-28
*/
@Data
public class PerfMonthScope implements Serializable {
private static final long serialVersionUID = 1L;
private String month;
private String role;
private String account;
/**
* 总分 = Σ(各项得分×权重) + Σ加分,保留 1 位小数
*/
private Double total;
private List<Item> items = new ArrayList<>();
private List<Bonus> bonus = new ArrayList<>();
private List<Appeal> appeals = new ArrayList<>();
/**
* 单项得分。score 为 null 表示等待人工录入(manual 缺省)
*/
@Data
public static class Item implements Serializable {
private String itemKey;
private String itemName;
private Double weight;
/**
* 指标原始值(如完成率 92.5、Bug 密度 16.0)
*/
private Double raw;
/**
* CHG-041 指标过程值明细(分子/分母/率,如 "Σ指数314.03 / 达标105h = 299.0%"),弹窗「绩效数据」列展示用
*/
private String rawDetail;
/**
* 项内得分 0~100
*/
private Double score;
/**
* 项内扣分(满分 - 得分)
*/
private Double deduct;
/**
* 数据来源:auto 系统计算 / manual 人工录入
*/
private String source;
private String remark;
}
/**
* 加分/返还记录
*/
@Data
public static class Bonus implements Serializable {
private String itemKey;
private String name;
private Double points;
/**
* bonus 加分项 / refund 返还
*/
private String type;
}
/**
* 申诉记录(随 scopeJson 承载,id 规则:account@month#序号)
*/
@Data
public static class Appeal implements Serializable {
private String id;
private String itemKey;
private String reason;
/**
* pending 待复核 / upheld 维持原判 / overturned 撤销回滚
*/
private String status;
private String reviewer;
private String createDate;
private String reviewDate;
}
}
@@ -0,0 +1,340 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.sa.zentao.entity.ZtPerfConfig;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Map;
/**
* 绩效算分规则引擎(三期批2,PRD §7.5 通用规则)。
*
* 通用规则:加权扣分制,每项满分 100×权重,项内扣分「扣完即止」,总分 = Σ各项 + 加分项。
*
* ruleJson 支持的规则类型:
* <ul>
* <li>linear 线性扣分:{"type":"linear","fullScore":100,"baseline":100,"deductPer":1,"perUnit":1,"direction":"down"}
* direction=down 表示 raw 低于 baseline 每差 perUnit 扣 deductPer 分;up 表示 raw 高于 baseline 每超 perUnit 扣 deductPer 分。
* 例:Bug 密度 ≤15% 满分、每增 1% 扣 3 分 → {"baseline":15,"deductPer":3,"perUnit":1,"direction":"up"}</li>
* <li>threshold 分档:{"type":"threshold","bands":[{"min":100,"score":100},{"min":90,"score":40},{"min":0,"score":0}]}
* raw ≥ min 的第一档得分(bands 按 min 从大到小匹配)。</li>
* <li>manual 人工评分:{"type":"manual"},score 缺省为 null(等待人工录入,不计入总分)。</li>
* <li>bonus 加分项(enabled=1 且 ruleJson.type=bonus):
* {"type":"bonus","bonus":10,"conditions":[{"metric":"bugCount","op":"lt","value":6},{"metric":"total","op":"gte","value":95}]}
* metric 取指标原始值,"total" 特指当前总分;op ∈ lt/lte/gt/gte/eq;全部条件满足则加分。</li>
* </ul>
*
* 特殊返还逻辑(如后端 Bug 密度连续 3 月达标返还半年扣分)作为 linear 规则的扩展参数:
* {"refund":{"threshold":15,"direction":"lte","consecutiveMonths":3}}
* 引擎通过 context 取数判定:
* <ul>
* <li>context["metricHistory"]:Map&lt;itemKey, List&lt;Double&gt;&gt; 历史月份原始值(近→远,不含本月)</li>
* <li>context["deductedPoints"]:Map&lt;itemKey, Double&gt; 返还窗口内该项累计扣分</li>
* <li>context["preScoredItems"]:Set&lt;itemKey&gt; 指标值已是 0~100 得分(批1 PerfIndicatorCalculator 产出),引擎直接采信不再套规则</li>
* </ul>
* 本月 raw 达标且最近 consecutiveMonths-1 个月历史均达标 → 返还 deductedPoints。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class PerfScoreEngine {
public static final String SOURCE_AUTO = "auto";
public static final String SOURCE_MANUAL = "manual";
public static final String CTX_METRIC_HISTORY = "metricHistory";
public static final String CTX_DEDUCTED_POINTS = "deductedPoints";
/**
* context key:Set&lt;itemKey&gt;,表示这些指标的 metrics 值已是 0~100 得分(批1计算器产出),引擎直接采信
*/
public static final String CTX_PRESCORED_ITEMS = "preScoredItems";
/**
* 按岗位配置计算某 account 某月得分。
*
* @param configs 该岗位 zt_perf_config 列表(仅 enabled=1 生效)
* @param metrics itemKey → 指标原始值(缺 key 视为无数据,auto 项 score 为 null)
* @param context 扩展上下文(返还判定用,可为 null)
*/
public PerfMonthScope compute(String account, String month, String role,
List<ZtPerfConfig> configs, Map<String, Double> metrics,
Map<String, Object> context) {
PerfMonthScope scope = new PerfMonthScope();
scope.setMonth(month);
scope.setRole(role);
scope.setAccount(account);
if (CollectionUtils.isEmpty(configs)) {
scope.setTotal(0D);
return scope;
}
double subtotal = 0D;
for (ZtPerfConfig config : configs) {
if (config.getEnabled() == null || config.getEnabled() != 1) {
continue;
}
JSONObject rule = parseRule(config.getRuleJson());
String type = rule == null ? null : rule.getString("type");
if ("bonus".equals(type)) {
continue;
}
PerfMonthScope.Item item = buildItem(config, rule, type, metrics, context);
scope.getItems().add(item);
if (item.getScore() != null) {
subtotal += item.getScore() * (item.getWeight() == null ? 0D : item.getWeight());
}
}
evalBonus(configs, metrics, subtotal, scope);
evalRefund(configs, metrics, context, scope);
double bonusTotal = 0D;
for (PerfMonthScope.Bonus b : scope.getBonus()) {
bonusTotal += b.getPoints() == null ? 0D : b.getPoints();
}
scope.setTotal(round1(subtotal + bonusTotal));
return scope;
}
/**
* 重算总分(人工评分合并 / 申诉回滚后调用),保持 1 位小数。
*/
public void recomputeTotal(PerfMonthScope scope) {
double total = 0D;
if (scope.getItems() != null) {
for (PerfMonthScope.Item item : scope.getItems()) {
if (item.getScore() != null) {
total += item.getScore() * (item.getWeight() == null ? 0D : item.getWeight());
}
}
}
if (scope.getBonus() != null) {
for (PerfMonthScope.Bonus b : scope.getBonus()) {
total += b.getPoints() == null ? 0D : b.getPoints();
}
}
scope.setTotal(round1(total));
}
@SuppressWarnings("unchecked")
private PerfMonthScope.Item buildItem(ZtPerfConfig config, JSONObject rule, String type, Map<String, Double> metrics,
Map<String, Object> context) {
PerfMonthScope.Item item = new PerfMonthScope.Item();
item.setItemKey(config.getItemKey());
item.setItemName(config.getItemName());
item.setWeight(config.getWeight() == null ? null : config.getWeight().doubleValue());
Double raw = metrics == null ? null : metrics.get(config.getItemKey());
item.setRaw(raw);
if ("manual".equals(type)) {
// 人工评分:缺省 null,等待人工录入(PRD §7.5 ❌ 项)
item.setSource(SOURCE_MANUAL);
item.setScore(null);
return item;
}
item.setSource(SOURCE_AUTO);
if (raw == null || rule == null) {
item.setScore(null);
return item;
}
double fullScore = rule.getDoubleValue("fullScore") == 0D && !rule.containsKey("fullScore")
? 100D : rule.getDoubleValue("fullScore");
double score;
// 批1计算器对接:已算好 0~100 分的指标直接采信,引擎不再重复套规则(防双重扣分)
Object preScored = context == null ? null : context.get(CTX_PRESCORED_ITEMS);
if (preScored instanceof java.util.Set && ((java.util.Set<String>) preScored).contains(config.getItemKey())) {
score = Math.max(0D, Math.min(fullScore, raw));
} else if ("threshold".equals(type)) {
score = evalThreshold(rule, raw, fullScore);
} else {
// 默认按 linear 处理
score = evalLinear(rule, raw, fullScore);
}
item.setScore(round1(score));
item.setDeduct(round1(fullScore - score));
return item;
}
/**
* linear:每减/增 perUnit 扣 deductPer 分,项内扣分扣完即止(封顶 fullScore)
*/
private double evalLinear(JSONObject rule, double raw, double fullScore) {
String direction = rule.getString("direction");
double baseline = rule.containsKey("baseline")
? rule.getDoubleValue("baseline")
: ("up".equals(direction) ? 0D : 100D);
double deductPer = rule.getDoubleValue("deductPer");
double perUnit = rule.getDoubleValue("perUnit") == 0D ? 1D : rule.getDoubleValue("perUnit");
double delta = "up".equals(direction) ? raw - baseline : baseline - raw;
if (delta <= 0) {
return fullScore;
}
double deduct = delta / perUnit * deductPer;
if (deduct > fullScore) {
deduct = fullScore;
}
return fullScore - deduct;
}
/**
* threshold:bands 按 min 从大到小,取 raw ≥ min 的第一档
*/
private double evalThreshold(JSONObject rule, double raw, double fullScore) {
JSONArray bands = rule.getJSONArray("bands");
if (bands == null || bands.isEmpty()) {
return fullScore;
}
Double best = null;
double bestMin = Double.NEGATIVE_INFINITY;
for (int i = 0; i < bands.size(); i++) {
JSONObject band = bands.getJSONObject(i);
double min = band.getDoubleValue("min");
if (raw >= min && min > bestMin) {
bestMin = min;
best = band.getDoubleValue("score");
}
}
return best == null ? 0D : best;
}
/**
* 加分项:enabled=1 且 type=bonus,全部条件满足则加 rule.bonus 分
*/
private double evalBonus(List<ZtPerfConfig> configs, Map<String, Double> metrics, double subtotal, PerfMonthScope scope) {
double bonusTotal = 0D;
for (ZtPerfConfig config : configs) {
if (config.getEnabled() == null || config.getEnabled() != 1) {
continue;
}
JSONObject rule = parseRule(config.getRuleJson());
if (rule == null || !"bonus".equals(rule.getString("type"))) {
continue;
}
double currentTotal = subtotal + bonusTotal;
if (!matchConditions(rule.getJSONArray("conditions"), metrics, currentTotal)) {
continue;
}
PerfMonthScope.Bonus bonus = new PerfMonthScope.Bonus();
bonus.setItemKey(config.getItemKey());
bonus.setName(config.getItemName());
bonus.setPoints(rule.getDoubleValue("bonus"));
bonus.setType("bonus");
scope.getBonus().add(bonus);
bonusTotal += bonus.getPoints();
}
return bonusTotal;
}
/**
* 返还判定:本月达标 + 最近 consecutiveMonths-1 个月历史达标 → 返还窗口内累计扣分
*/
@SuppressWarnings("unchecked")
private void evalRefund(List<ZtPerfConfig> configs, Map<String, Double> metrics,
Map<String, Object> context, PerfMonthScope scope) {
if (context == null) {
return;
}
Map<String, List<Double>> history = (Map<String, List<Double>>) context.get(CTX_METRIC_HISTORY);
Map<String, Double> deductedPoints = (Map<String, Double>) context.get(CTX_DEDUCTED_POINTS);
if (history == null || deductedPoints == null) {
return;
}
for (ZtPerfConfig config : configs) {
if (config.getEnabled() == null || config.getEnabled() != 1) {
continue;
}
JSONObject rule = parseRule(config.getRuleJson());
JSONObject refund = rule == null ? null : rule.getJSONObject("refund");
if (refund == null) {
continue;
}
String itemKey = config.getItemKey();
double threshold = refund.getDoubleValue("threshold");
boolean lte = !"gte".equals(refund.getString("direction"));
int consecutiveMonths = refund.getIntValue("consecutiveMonths") == 0 ? 3 : refund.getIntValue("consecutiveMonths");
Double raw = metrics == null ? null : metrics.get(itemKey);
if (raw == null || !(lte ? raw <= threshold : raw >= threshold)) {
continue;
}
List<Double> past = history.get(itemKey);
if (past == null || past.size() < consecutiveMonths - 1) {
continue;
}
boolean allPass = true;
for (int i = 0; i < consecutiveMonths - 1; i++) {
Double h = past.get(i);
if (h == null || !(lte ? h <= threshold : h >= threshold)) {
allPass = false;
break;
}
}
Double points = deductedPoints.get(itemKey);
if (allPass && points != null && points > 0) {
PerfMonthScope.Bonus bonus = new PerfMonthScope.Bonus();
bonus.setItemKey(itemKey);
bonus.setName((StringUtils.isBlank(config.getItemName()) ? itemKey : config.getItemName()) + "扣分返还");
bonus.setPoints(points);
bonus.setType("refund");
scope.getBonus().add(bonus);
}
}
}
private boolean matchConditions(JSONArray conditions, Map<String, Double> metrics, double total) {
if (conditions == null || conditions.isEmpty()) {
return false;
}
for (int i = 0; i < conditions.size(); i++) {
JSONObject cond = conditions.getJSONObject(i);
String metric = cond.getString("metric");
Double actual = "total".equals(metric) ? total : (metrics == null ? null : metrics.get(metric));
if (actual == null || !compare(actual, cond.getString("op"), cond.getDoubleValue("value"))) {
return false;
}
}
return true;
}
private boolean compare(double actual, String op, double value) {
switch (op == null ? "" : op) {
case "lt":
return actual < value;
case "lte":
return actual <= value;
case "gt":
return actual > value;
case "gte":
return actual >= value;
case "eq":
return actual == value;
default:
return false;
}
}
private JSONObject parseRule(String ruleJson) {
if (StringUtils.isBlank(ruleJson)) {
return null;
}
try {
return JSON.parseObject(ruleJson);
} catch (Exception e) {
return null;
}
}
private double round1(double v) {
return BigDecimal.valueOf(v).setScale(1, RoundingMode.HALF_UP).doubleValue();
}
}
@@ -0,0 +1,19 @@
package com.sa.zentao.service.perf;
import org.springframework.stereotype.Component;
/**
* 产品缺陷率(产品经理/产品助理 0.15):与线上Bug率同口径(prd §7.3),
* ≤5‰ 满分、普通扣3/重大扣10。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class ProductDefectRateCalculator extends AbstractWeightedBugCalculator {
@Override
public String key() {
return "productDefectRate";
}
}
@@ -0,0 +1,50 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtStory;
import com.sa.zentao.service.IZtStoryService;
import com.sa.zentao.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
/**
* 项目准时率(产品经理 0.1):Σ(当月准时上线需求量) ÷ Σ(当月规划上线需求总量)。
* 规划 = zt_story.planEndDate 落在当月;准时 = releaseddate ≤ planEndDate(当日末)。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class ProjectPunctualityRateCalculator extends AbstractPerfIndicatorCalculator {
@Autowired
private IZtStoryService storyService;
@Override
public String key() {
return "projectPunctualityRate";
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
List<ZtStory> stories = storyService.list(new QueryWrapper<ZtStory>().lambda()
.ge(ZtStory::getPlanEndDate, firstDayOfMonth(month))
.le(ZtStory::getPlanEndDate, lastDayOfMonth(month)));
if (CollectionUtils.isEmpty(stories)) {
rawDetail("当月无规划上线需求");
return BigDecimal.ZERO;
}
long punctual = stories.stream()
.filter(o -> o.getReleaseddate() != null
&& o.getReleaseddate().getTime() <= DateUtils.getDayLast(o.getPlanEndDate()).getTime())
.count();
BigDecimal rate = percent(BigDecimal.valueOf(punctual), BigDecimal.valueOf(stories.size()));
rawDetail("准时 " + punctual + " / 规划 " + stories.size() + " = " + rate.stripTrailingZeros().toPlainString() + "%");
return applyRule(rule, rate);
}
}
@@ -0,0 +1,23 @@
package com.sa.zentao.service.perf;
import org.springframework.stereotype.Component;
/**
* 任务及时完成率(后端/前端 0.25 分段扣分;UI 0.5 阈值分档——规则参数在 zt_perf_config)。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class TaskOnTimeRateCalculator extends AbstractTaskOnTimeRateCalculator {
@Override
public String key() {
return "taskOnTimeRate";
}
@Override
protected String typeFilter() {
return null;
}
}
@@ -0,0 +1,56 @@
package com.sa.zentao.service.perf;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtDocCheck;
import com.sa.zentao.entity.ZtStory;
import com.sa.zentao.entity.ZtStoryExpand;
import com.sa.zentao.service.IZtStoryExpandService;
import com.sa.zentao.service.impl.ZtDocCheckServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 测试文档齐备(测试工程师 0.25,10%抽检):《需求测试用例》+《需求测试报告》每缺一份扣3分;
* 归属 = zt_story_expand.test_person 含该账号。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class TestDocReadyCalculator extends AbstractDocReadyCalculator {
@Autowired
private IZtStoryExpandService storyExpandService;
@Override
public String key() {
return "testDocReady";
}
@Override
protected Set<String> docTypes() {
return new HashSet<>(Arrays.asList(ZtDocCheckServiceImpl.DOC_TEST_CASE, ZtDocCheckServiceImpl.DOC_TEST_REPORT));
}
@Override
protected int defaultDeductPerMissing() {
return 3;
}
@Override
protected boolean ownRow(ZtDocCheck row, Map<Integer, ZtStory> storyMap, String account) {
List<ZtStoryExpand> expands = storyExpandService.list(new QueryWrapper<ZtStoryExpand>().lambda()
.eq(ZtStoryExpand::getStoryId, row.getStoryId())
.like(ZtStoryExpand::getTestPerson, account)
.last("LIMIT 1"));
return !CollectionUtils.isEmpty(expands);
}
}
@@ -0,0 +1,23 @@
package com.sa.zentao.service.perf;
import org.springframework.stereotype.Component;
/**
* 测试计划及时完成率(测试工程师 0.2):zt_task type=test 口径,每减1%扣2分。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class TestPlanOnTimeRateCalculator extends AbstractTaskOnTimeRateCalculator {
@Override
public String key() {
return "testPlanOnTimeRate";
}
@Override
protected String typeFilter() {
return "test";
}
}
@@ -0,0 +1,111 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtStory;
import com.sa.zentao.entity.ZtStoryExpand;
import com.sa.zentao.service.IZtStoryExpandService;
import com.sa.zentao.service.IZtStoryService;
import com.sa.zentao.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 版本计划完成率(项目经理):Σ(按时发布需求工作量指数) ÷ Σ(当月发布需求工作量指数)。
* 按时 = zt_story.releaseddate ≤ planEndDate(当日末)。
* CHG-045 加权由 zt_story.estimate 改为 zt_story_expand.workload_index(用户拍板:
* estimate 全线未填公式失效;无 expand 行/指数为空按 0 计,自然不进分子分母)。
* CHG-049 项目经理(含王宇航变体)改项目口径(用户拍板"孙世超是飞侠的为啥不区分"):
* 只统计他当月窗口内执行关联产品的发布需求。
* CHG-079 计划结束时间(planEndDate)未录入的需求不参与计算(用户拍板 2026-09-01):
* 此前"进分母不进分子"按不按时惩罚,现分子分母都剔除;剔除后分母为 0 时与"当月无发布需求"一致按 0 分。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class VersionPlanRateCalculator extends AbstractWorkloadRateCalculator {
@Autowired
private IZtStoryExpandService storyExpandService;
@Override
public String key() {
return "versionPlanRate";
}
@Override
protected Set<Integer> scopeStoryIds(String account, String month) {
// 本计算器不走完成率管道(doCompute 重写),钩子占位
return null;
}
@Override
protected int producerCount(String account, String month) {
return 1;
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
List<ZtStory> stories = storyService.list(new QueryWrapper<ZtStory>().lambda()
.ge(ZtStory::getReleaseddate, firstDayOfMonth(month))
.le(ZtStory::getReleaseddate, lastDayOfMonth(month)));
if (CollectionUtils.isEmpty(stories)) {
rawDetail("当月无发布需求");
return BigDecimal.ZERO;
}
//CHG-049 项目经理(含王宇航变体)改项目口径(用户拍板"孙世超是飞侠的为啥不区分"):
//只统计他当月窗口内执行关联产品的发布需求;其余角色=全表(当前无其他角色使用本指标)
if (isProjectManager(account)) {
Set<Integer> projectStoryIds = projectScopeStoryIds(account, month);
stories = stories.stream().filter(o -> projectStoryIds.contains(o.getId())).collect(Collectors.toList());
if (stories.isEmpty()) {
rawDetail("当月无发布需求(项目范围)");
return BigDecimal.ZERO;
}
}
Map<Integer, BigDecimal> indexMap = storyExpandService.list(new QueryWrapper<ZtStoryExpand>().lambda()
.in(ZtStoryExpand::getStoryId, stories.stream().map(ZtStory::getId).collect(Collectors.toList())))
.stream().collect(Collectors.toMap(ZtStoryExpand::getStoryId, o -> parseIndex(o.getWorkloadIndex()), (a, b) -> a));
BigDecimal totalWeight = BigDecimal.ZERO;
BigDecimal onTimeWeight = BigDecimal.ZERO;
//CHG-079 计划结束时间未录入的需求不参与计算:分子分母都剔除,不再按"不按时"压进分母
int noPlanEndCount = 0;
for (ZtStory s : stories) {
if (s.getPlanEndDate() == null) {
noPlanEndCount++;
continue;
}
BigDecimal w = indexMap.getOrDefault(s.getId(), BigDecimal.ZERO);
totalWeight = totalWeight.add(w);
if (s.getReleaseddate().getTime() <= DateUtils.getDayLast(s.getPlanEndDate()).getTime()) {
onTimeWeight = onTimeWeight.add(w);
}
}
BigDecimal rate = percent(onTimeWeight, totalWeight);
rawDetail("按时 " + onTimeWeight.stripTrailingZeros().toPlainString() + " / 总发布 " + totalWeight.stripTrailingZeros().toPlainString()
+ "(指数加权,发布 " + stories.size() + " 个,未录计划时间剔除 " + noPlanEndCount + " 个)= " + rate.stripTrailingZeros().toPlainString() + "%");
return applyRule(rule, rate);
}
/**
* workload_index 为 String 列,非数字/空按 0 计
*/
private BigDecimal parseIndex(String workloadIndex) {
if (workloadIndex == null || workloadIndex.trim().isEmpty()) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(workloadIndex.trim());
} catch (NumberFormatException e) {
return BigDecimal.ZERO;
}
}
}
@@ -0,0 +1,92 @@
package com.sa.zentao.service.perf;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtEffort;
import com.sa.zentao.entity.ZtUser;
import com.sa.zentao.dao.ItApproval;
import com.sa.zentao.enums.UserType;
import com.sa.zentao.service.IZtEffortService;
import com.sa.zentao.service.IZtTaskService;
import com.sa.zentao.service.IZtUserService;
import com.sa.zentao.service.impl.IZtCountService;
import com.sa.zentao.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Date;
import java.util.List;
/**
* 工作量饱和度(后端 0.2/前端 0.3):当月实际产出工时 ÷ 月度达标工时,每减1%扣2分。
* 达标工时口径(CHG-028 用户拍板):(当月工作天数 − 请假天数)×5,请假按小时/8 折算成天(半天=0.5);
* 实际产出 = zt_effort.consumed 当月合计。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class WorkSaturationCalculator extends AbstractPerfIndicatorCalculator {
@Autowired
private IZtEffortService effortService;
@Autowired
private IZtUserService userService;
@Autowired
private IZtTaskService taskService;
@Autowired
private IZtCountService countService;
@Override
public String key() {
return "workSaturation";
}
@Override
protected BigDecimal doCompute(String account, String month, JSONObject rule) {
Date d = monthToDate(month);
Date firstDay = firstDayOfMonth(month);
Date lastDay = lastDayOfMonth(month);
// CHG-034 严格按 xlsx:达标工时(每人) = (工作天数×团队人数 − 团队请假总天数)×5 ÷ 团队人数
// 团队人数=后端+前端(UserType.KFZ);请假全团队平摊,不再只扣本人
int workDays = DateUtils.getWorkDaysInCurrentMonth(d);
BigDecimal examineTime = teamExamineTime(workDays, firstDay, lastDay);
if (examineTime.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
List<ZtEffort> efforts = effortService.list(new QueryWrapper<ZtEffort>().lambda()
.eq(ZtEffort::getAccount, account)
.ge(ZtEffort::getDate, firstDay)
.le(ZtEffort::getDate, lastDay));
BigDecimal actual = sumFloat(efforts.stream().map(ZtEffort::getConsumed).collect(java.util.stream.Collectors.toList()));
BigDecimal saturation = percent(actual, examineTime);
return applyRule(rule, saturation);
}
/**
* 团队口径达标工时(每人相同):(workDays×devCount − 团队请假小时÷8)×5 ÷ devCount
*/
private BigDecimal teamExamineTime(int workDays, Date firstDay, Date lastDay) {
List<ZtUser> devs = userService.list(new QueryWrapper<ZtUser>().lambda()
.eq(ZtUser::getUserType, UserType.KFZ)
.ne(ZtUser::getDeleted, "1"));
int devCount = devs == null ? 0 : devs.size();
if (devCount == 0) {
return BigDecimal.ZERO;
}
BigDecimal teamLeaveHours = BigDecimal.ZERO;
for (ZtUser dev : devs) {
List<ItApproval> approvalList = taskService.itApprovalByUserName(dev.getNickname(), firstDay, lastDay);
BigDecimal hours = countService.getApprovalTime(approvalList, firstDay);
if (hours != null) {
teamLeaveHours = teamLeaveHours.add(hours);
}
}
BigDecimal teamLeaveDays = teamLeaveHours.divide(BigDecimal.valueOf(8), 2, RoundingMode.HALF_UP);
return BigDecimal.valueOf((long) workDays * devCount).subtract(teamLeaveDays)
.multiply(BigDecimal.valueOf(5))
.divide(BigDecimal.valueOf(devCount), 2, RoundingMode.HALF_UP);
}
}
@@ -0,0 +1,59 @@
package com.sa.zentao.service.perf;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.sa.zentao.entity.ZtStoryExpand;
import com.sa.zentao.entity.ZtUser;
import com.sa.zentao.service.IZtStoryExpandService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* 需求PRD工作量指标完成率(项目经理/产品经理/产品助理):
* 个人口径,范围 = zt_story_expand.product_person 含该账号的需求,分母 = 工作天数×5。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class WorkloadRatePrdCalculator extends AbstractWorkloadRateCalculator {
@Autowired
private IZtStoryExpandService storyExpandService;
@Override
public String key() {
return "workloadRatePrd";
}
@Override
protected Set<Integer> scopeStoryIds(String account, String month) {
//CHG-048 项目口径扩到产品经理/助理(用户拍板"跟项目管理员一样的方案"):
//projectManager/projectManagerWyh/productManager/productAssistant 统一按项目口径——
//范围=当月窗口内执行关联产品的需求;其余角色走 product_person 个人口径
if (isProjectScopedRole(account)) {
return projectScopeStoryIds(account, month);
}
//CHG-039 product_person 实际存中文姓名(如“魏冬霞”,多人逗号分隔),按 account 匹配恒为空 → 按昵称匹配,account 兜底
ZtUser u = userService.getByAccount(account);
String nameKey = (u != null && u.getNickname() != null && !u.getNickname().isEmpty())
? u.getNickname() : account;
return toStoryIds(storyExpandService.list(new QueryWrapper<ZtStoryExpand>().lambda()
.like(ZtStoryExpand::getProductPerson, nameKey)
.or().like(ZtStoryExpand::getProductPerson, account)));
}
@Override
protected int producerCount(String account, String month) {
//CHG-048 四角色统一项目口径:分母=当月窗口内执行的 KFZ 成员数
if (isProjectScopedRole(account)) {
return projectProducerCount(account, month);
}
return 1;
}
/**
* CHG-048 项目口径角色判断已上移至 AbstractWorkloadRateCalculator.isProjectScopedRole
*/
}
@@ -0,0 +1,41 @@
package com.sa.zentao.service.perf;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* 团队工作量指标完成率(项目经理/产品经理):
* 团队口径,范围不限需求,分母 = 工作天数×5×开发产出人数(测试人员不计入产出方)。
*
* @author ai
* @since 2026-07-28
*/
@Component
public class WorkloadRateTeamCalculator extends AbstractWorkloadRateCalculator {
@Override
public String key() {
return "workloadRateTeam";
}
@Override
protected Set<Integer> scopeStoryIds(String account, String month) {
//CHG-053 团队完成率项目口径四角色(含选中产品集路径):projectManager/projectManagerWyh/
//productManager/productAssistant=项目口径(选中产品集优先,否则本人在窗执行关联产品);其余=部门口径
if (isProjectScopedRole(account)) {
return projectScopeStoryIds(account, month);
}
// 团队口径:不限制需求范围
return null;
}
@Override
protected int producerCount(String account, String month) {
//CHG-053 四角色统一项目口径:选中产品集下在窗执行成员数,否则本人在窗执行成员数;其余=全部门 KFZ
if (isProjectScopedRole(account)) {
return projectProducerCount(account, month);
}
return devProducerCount();
}
}
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.sa.zentao.entity.ZtCronDevops; import com.sa.zentao.entity.ZtCronDevops;
import com.sa.zentao.service.IZtCronDevopsService; import com.sa.zentao.service.IZtCronDevopsService;
import com.sa.zentao.service.IZtDocCheckService;
import com.sa.zentao.service.IZtYwTaskService; import com.sa.zentao.service.IZtYwTaskService;
import com.sa.zentao.utils.Constant; import com.sa.zentao.utils.Constant;
import com.sa.zentao.utils.DateUtils; import com.sa.zentao.utils.DateUtils;
@@ -31,6 +32,9 @@ public class SpringTaskJob {
@Autowired @Autowired
private IZtYwTaskService ywTaskService; private IZtYwTaskService ywTaskService;
@Autowired
private IZtDocCheckService docCheckService;
//每天凌晨一点去 //每天凌晨一点去
@Scheduled(cron ="0 0 0 * * ?") @Scheduled(cron ="0 0 0 * * ?")
public void dkEveryDay(){ public void dkEveryDay(){
@@ -73,4 +77,13 @@ public class SpringTaskJob {
} }
//每月28日凌晨2点预生成当月文档齐备快照(FR-014 三期)
@Scheduled(cron ="0 0 2 28 * ?")
public void docCheckMonthEnd(){
docCheckService.generateMonthSnapshot(DateUtils.formatDate(new Date(), "yyyy-MM"));
}
} }
@@ -0,0 +1,134 @@
package com.sa.zentao.utils;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 绩效得分规则(SRC-002 新版考核方案,后端开发工程师口径):
* 任务及时完成 25 分 / Bug 密度 30 分 / 工作量饱和度 20 分,
* 代码质量 10 分 / 文档质量 10 分 / 不规范行为 5 分(后三项人工评审,给满分默认值)。
* 纯函数,供 IZtCountService 月报(buildKFZScore)与地盘绩效(myWorkScore)共用,保证两处一致。
*
* @author ai
* @since 2026-07-29 (CHG-036)
*/
public final class PerfScoreRules {
private PerfScoreRules() {
}
public static final BigDecimal FULL_PUNCTUALITY = BigDecimal.valueOf(25);
public static final BigDecimal FULL_BUG_DENSITY = BigDecimal.valueOf(30);
public static final BigDecimal FULL_SATURATION = BigDecimal.valueOf(20);
/** 前端工程师饱和度满分(CHG-038:前端表无文档质量项,10% 挪至饱和度) */
public static final BigDecimal FULL_SATURATION_FRONT = BigDecimal.valueOf(30);
public static final BigDecimal FULL_CODE_QUALITY = BigDecimal.valueOf(10);
public static final BigDecimal FULL_DOCUMENT_QUALITY = BigDecimal.valueOf(10);
public static final BigDecimal FULL_WORK_ATTITUDE = BigDecimal.valueOf(5);
/**
* 任务及时完成(25 分):=100% 满分;95%~100% 每减1%扣1分;≤94% 每减1%扣2分(分段累计)。
*
* @param rate 及时率,0~1(如 0.973)
*/
public static BigDecimal punctualityScore(BigDecimal rate) {
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
BigDecimal pct = rate.multiply(BigDecimal.valueOf(100));
BigDecimal deduct = BigDecimal.ZERO;
if (pct.compareTo(BigDecimal.valueOf(100)) < 0) {
// 95~100 段:from=100 到 max(pct,95),每 1% 扣 1 分
BigDecimal seg1 = BigDecimal.valueOf(100).subtract(pct.max(BigDecimal.valueOf(95)));
deduct = deduct.add(seg1);
// ≤94 段:from=95 到 pct,每 1% 扣 2 分
if (pct.compareTo(BigDecimal.valueOf(95)) < 0) {
deduct = deduct.add(BigDecimal.valueOf(95).subtract(pct).multiply(BigDecimal.valueOf(2)));
}
}
return clamp(FULL_PUNCTUALITY.subtract(deduct));
}
/**
* Bug 密度(30 分):≤15% 满分;>15% 每增 1% 扣 3 分,扣完截止(无截断归零)。
*
* @param densityPct 密度百分数(如 16.5 表示 16.5%)
*/
public static BigDecimal bugDensityScore(BigDecimal densityPct) {
if (densityPct == null) {
return BigDecimal.ZERO;
}
if (densityPct.compareTo(BigDecimal.valueOf(15)) <= 0) {
return FULL_BUG_DENSITY;
}
BigDecimal deduct = densityPct.subtract(BigDecimal.valueOf(15)).multiply(BigDecimal.valueOf(3));
return clamp(FULL_BUG_DENSITY.subtract(deduct));
}
/**
* 工作量饱和度(20 分,后端口径):=100% 满分;<100% 每减 1% 扣 2 分,扣完截止。
*
* @param rate 达标率,0~1(如 1.25 表示 125%)
*/
public static BigDecimal saturationScore(BigDecimal rate) {
return saturationScore(rate, FULL_SATURATION);
}
/**
* 工作量饱和度(指定满分):=100% 满分;<100% 每减 1% 扣 2 分,扣完截止。
* CHG-038 前端工程师口径满分 30(前端表无文档质量项,10% 挪至饱和度)。
*
* @param rate 达标率,0~1
* @param full 满分(后端 20 / 前端 30)
*/
public static BigDecimal saturationScore(BigDecimal rate, BigDecimal full) {
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
BigDecimal pct = rate.multiply(BigDecimal.valueOf(100));
if (pct.compareTo(BigDecimal.valueOf(100)) >= 0) {
return full;
}
BigDecimal deduct = BigDecimal.valueOf(100).subtract(pct).multiply(BigDecimal.valueOf(2));
return clamp(full.subtract(deduct));
}
private static BigDecimal clamp(BigDecimal score) {
if (score.compareTo(BigDecimal.ZERO) < 0) {
return BigDecimal.ZERO;
}
// toPlainString 归一,避免 stripTrailingZeros 产生 1E+1 科学计数形导致 equals 比较失败
return new BigDecimal(score.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString());
}
/**
* 测试计划及时完成(20 分,测试工程师):=100% 满分;<100% 每减 1% 扣 2 分,扣完截止。CHG-037
*/
public static BigDecimal testerPlanScore(BigDecimal rate) {
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
BigDecimal pct = rate.multiply(BigDecimal.valueOf(100));
if (pct.compareTo(BigDecimal.valueOf(100)) >= 0) {
return BigDecimal.valueOf(20);
}
return clamp(BigDecimal.valueOf(20).subtract(BigDecimal.valueOf(100).subtract(pct).multiply(BigDecimal.valueOf(2))));
}
/**
* UI 任务及时完成(50 分,UI 工程师):=100% 得 50;90%~100% 得 40;<90% 得 0。CHG-037
*/
public static BigDecimal uiPunctualityScore(BigDecimal rate) {
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
BigDecimal pct = rate.multiply(BigDecimal.valueOf(100));
if (pct.compareTo(BigDecimal.valueOf(100)) >= 0) {
return BigDecimal.valueOf(50);
}
if (pct.compareTo(BigDecimal.valueOf(90)) >= 0) {
return BigDecimal.valueOf(40);
}
return BigDecimal.ZERO;
}
}
@@ -22,6 +22,7 @@
<result column="product_person" property="productPerson" /> <result column="product_person" property="productPerson" />
<result column="develop_person" property="developPerson" /> <result column="develop_person" property="developPerson" />
<result column="test_person" property="testPerson" /> <result column="test_person" property="testPerson" />
<result column="acceptance_criteria" property="acceptanceCriteria" />
</resultMap> </resultMap>
<select id="queryByMonthWithTitle" resultMap="BaseResultMap"> <select id="queryByMonthWithTitle" resultMap="BaseResultMap">
@@ -347,6 +347,18 @@
</select>
<!-- 批量版:条件与 itApprovalByUserName 完全一致,name 改 IN;供 storyBarChart 月度循环批量化(老 SQL 不动) -->
<select id="itApprovalsByNames" resultType="com.sa.zentao.dao.ItApproval">
SELECT * from os_system.it_approval a WHERE 1=1
and a.name in
<foreach collection="names" item="n" open="(" separator="," close=")">#{n}</foreach>
and
(
(date(apply_time_start) BETWEEN date( #{startDate}) and date(#{endDate}))
or(
date(apply_time_end) BETWEEN date(#{startDate}) and date(#{endDate}))
)
</select> </select>
<select id="taskListByIds" resultType="com.sa.zentao.entity.ZtTask"> <select id="taskListByIds" resultType="com.sa.zentao.entity.ZtTask">
select select
Binary file not shown.