Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4df7e06f6 |
@@ -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<itemKey, List<Double>> 历史月份原始值(近→远,不含本月)</li>
|
||||||
|
* <li>context["deductedPoints"]:Map<itemKey, Double> 返还窗口内该项累计扣分</li>
|
||||||
|
* <li>context["preScoredItems"]:Set<itemKey> 指标值已是 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<itemKey>,表示这些指标的 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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user