From a4df7e06f6394bbb53167c98d1fe85b3f8773fd3 Mon Sep 17 00:00:00 2001 From: guoqibing Date: Thu, 8 Oct 2026 14:34:39 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A9=E6=95=88=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 8 +- .../zentao/controller/CommonsController.java | 81 +- .../zentao/controller/ZtCountController.java | 51 + .../zentao/controller/ZtPerfController.java | 176 +++ .../controller/ZtStoryExpandController.java | 15 + .../zentao/controller/ZtTaskController.java | 10 + .../zentao/controller/ZtUserController.java | 2 + .../com/sa/zentao/dao/PerformanceDTO.java | 27 + .../java/com/sa/zentao/dao/UploadDTO.java | 14 + .../java/com/sa/zentao/dao/ZtStoryDTO.java | 18 + .../com/sa/zentao/dao/ZtStoryUserDTO.java | 25 +- .../com/sa/zentao/dao/ZtTaskAiBatchDTO.java | 39 + .../java/com/sa/zentao/dao/ZtUserDTO.java | 3 + .../java/com/sa/zentao/entity/ZtDocCheck.java | 114 ++ .../java/com/sa/zentao/entity/ZtFile.java | 3 + .../java/com/sa/zentao/entity/ZtMeeting.java | 5 + .../com/sa/zentao/entity/ZtPerfConfig.java | 69 + .../java/com/sa/zentao/entity/ZtStory.java | 45 + .../com/sa/zentao/entity/ZtStoryExpand.java | 11 + .../java/com/sa/zentao/entity/ZtUser.java | 3 + .../java/com/sa/zentao/enums/FileTypes.java | 8 + .../sa/zentao/mapper/ZtDocCheckMapper.java | 16 + .../sa/zentao/mapper/ZtPerfConfigMapper.java | 16 + .../com/sa/zentao/mapper/ZtTaskMapper.java | 6 + .../java/com/sa/zentao/qo/ZtDocCheckQo.java | 52 + src/main/java/com/sa/zentao/qo/ZtPerfQo.java | 66 + .../sa/zentao/service/IZtDocCheckService.java | 60 + .../com/sa/zentao/service/IZtFileService.java | 12 + .../zentao/service/IZtPerfConfigService.java | 30 + .../zentao/service/IZtPerfReportService.java | 40 + .../com/sa/zentao/service/IZtTaskService.java | 16 + .../zentao/service/impl/IZtCountService.java | 1163 ++++++++++++----- .../zentao/service/impl/ZtBugServiceImpl.java | 3 +- .../service/impl/ZtDocCheckServiceImpl.java | 464 +++++++ .../service/impl/ZtFileServiceImpl.java | 104 ++ .../service/impl/ZtMeetingServiceImpl.java | 22 + .../service/impl/ZtPerfConfigServiceImpl.java | 88 ++ .../service/impl/ZtPerfReportServiceImpl.java | 366 ++++++ .../service/impl/ZtProductServiceImpl.java | 11 +- .../service/impl/ZtProjectServiceImpl.java | 36 +- .../impl/ZtStoryExpandServiceImpl.java | 8 + .../service/impl/ZtStoryServiceImpl.java | 3 +- .../service/impl/ZtStoryUserServiceImpl.java | 23 + .../service/impl/ZtTaskServiceImpl.java | 171 +++ .../service/impl/ZtUserServiceImpl.java | 6 + .../perf/AbstractDocReadyCalculator.java | 74 ++ .../perf/AbstractOpsCountCalculator.java | 41 + .../perf/AbstractPerfIndicatorCalculator.java | 298 +++++ .../AbstractTaskOnTimeRateCalculator.java | 55 + .../perf/AbstractWeightedBugCalculator.java | 120 ++ .../perf/AbstractWorkloadRateCalculator.java | 249 ++++ .../service/perf/BugDensityCalculator.java | 58 + .../service/perf/CodeQualityCalculator.java | 116 ++ .../perf/DefectFindRateCalculator.java | 65 + .../service/perf/DocReadyScoreCalculator.java | 123 ++ .../service/perf/FixedTeamMonthTime.java | 26 + .../service/perf/MdContentResolver.java | 125 ++ .../service/perf/OnlineBugRateCalculator.java | 18 + .../service/perf/OpsBackupCalculator.java | 44 + .../service/perf/OpsInspectCalculator.java | 43 + .../service/perf/OpsMajorTaskCalculator.java | 63 + .../service/perf/OpsMonitorCalculator.java | 43 + .../service/perf/PerfIndicatorCalculator.java | 37 + .../zentao/service/perf/PerfMonthScope.java | 101 ++ .../zentao/service/perf/PerfScoreEngine.java | 340 +++++ .../perf/ProductDefectRateCalculator.java | 19 + .../ProjectPunctualityRateCalculator.java | 50 + .../perf/TaskOnTimeRateCalculator.java | 23 + .../service/perf/TestDocReadyCalculator.java | 56 + .../perf/TestPlanOnTimeRateCalculator.java | 23 + .../perf/VersionPlanRateCalculator.java | 111 ++ .../perf/WorkSaturationCalculator.java | 92 ++ .../perf/WorkloadRatePrdCalculator.java | 59 + .../perf/WorkloadRateTeamCalculator.java | 41 + .../com/sa/zentao/task/SpringTaskJob.java | 13 + .../com/sa/zentao/utils/PerfScoreRules.java | 134 ++ .../resources/mapper/ZtStoryExpandMapper.xml | 1 + src/main/resources/mapper/ZtTaskMapper.xml | 12 + .../resources/templates/scope/UI工程师.xlsx | Bin 11358 -> 7343 bytes .../templates/scope/产品经理考核.xlsx | Bin 12632 -> 7531 bytes .../templates/scope/前端开发工程师.xlsx | Bin 0 -> 7618 bytes .../templates/scope/后端开发工程师.xlsx | Bin 0 -> 8103 bytes .../resources/templates/scope/测试工程师.xlsx | Bin 11911 -> 7796 bytes .../resources/templates/scope/运维工程师.xlsx | Bin 0 -> 7459 bytes .../templates/scope/项目助理考核.xlsx | Bin 11132 -> 7509 bytes .../templates/scope/项目经理考核-王宇航.xlsx | Bin 0 -> 7931 bytes .../templates/scope/项目经理考核.xlsx | Bin 12723 -> 8007 bytes 87 files changed, 5821 insertions(+), 361 deletions(-) create mode 100644 src/main/java/com/sa/zentao/controller/ZtPerfController.java create mode 100644 src/main/java/com/sa/zentao/dao/ZtTaskAiBatchDTO.java create mode 100644 src/main/java/com/sa/zentao/entity/ZtDocCheck.java create mode 100644 src/main/java/com/sa/zentao/entity/ZtPerfConfig.java create mode 100644 src/main/java/com/sa/zentao/mapper/ZtDocCheckMapper.java create mode 100644 src/main/java/com/sa/zentao/mapper/ZtPerfConfigMapper.java create mode 100644 src/main/java/com/sa/zentao/qo/ZtDocCheckQo.java create mode 100644 src/main/java/com/sa/zentao/qo/ZtPerfQo.java create mode 100644 src/main/java/com/sa/zentao/service/IZtDocCheckService.java create mode 100644 src/main/java/com/sa/zentao/service/IZtPerfConfigService.java create mode 100644 src/main/java/com/sa/zentao/service/IZtPerfReportService.java create mode 100644 src/main/java/com/sa/zentao/service/impl/ZtDocCheckServiceImpl.java create mode 100644 src/main/java/com/sa/zentao/service/impl/ZtPerfConfigServiceImpl.java create mode 100644 src/main/java/com/sa/zentao/service/impl/ZtPerfReportServiceImpl.java create mode 100644 src/main/java/com/sa/zentao/service/perf/AbstractDocReadyCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/AbstractOpsCountCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/AbstractPerfIndicatorCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/AbstractTaskOnTimeRateCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/AbstractWeightedBugCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/AbstractWorkloadRateCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/BugDensityCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/CodeQualityCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/DefectFindRateCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/DocReadyScoreCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/FixedTeamMonthTime.java create mode 100644 src/main/java/com/sa/zentao/service/perf/MdContentResolver.java create mode 100644 src/main/java/com/sa/zentao/service/perf/OnlineBugRateCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/OpsBackupCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/OpsInspectCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/OpsMajorTaskCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/OpsMonitorCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/PerfIndicatorCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/PerfMonthScope.java create mode 100644 src/main/java/com/sa/zentao/service/perf/PerfScoreEngine.java create mode 100644 src/main/java/com/sa/zentao/service/perf/ProductDefectRateCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/ProjectPunctualityRateCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/TaskOnTimeRateCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/TestDocReadyCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/TestPlanOnTimeRateCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/VersionPlanRateCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/WorkSaturationCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/WorkloadRatePrdCalculator.java create mode 100644 src/main/java/com/sa/zentao/service/perf/WorkloadRateTeamCalculator.java create mode 100644 src/main/java/com/sa/zentao/utils/PerfScoreRules.java create mode 100644 src/main/resources/templates/scope/前端开发工程师.xlsx create mode 100644 src/main/resources/templates/scope/后端开发工程师.xlsx create mode 100644 src/main/resources/templates/scope/运维工程师.xlsx create mode 100644 src/main/resources/templates/scope/项目经理考核-王宇航.xlsx diff --git a/pom.xml b/pom.xml index c6338e5..6f21cc4 100644 --- a/pom.xml +++ b/pom.xml @@ -282,7 +282,7 @@ org.projectlombok lombok - 1.18.8 + 1.18.34 @@ -300,7 +300,11 @@ org.springframework.boot spring-boot-maven-plugin - + + org.apache.maven.plugins + maven-surefire-plugin + + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/main/java/com/sa/zentao/controller/CommonsController.java b/src/main/java/com/sa/zentao/controller/CommonsController.java index bc5c80c..388bafa 100644 --- a/src/main/java/com/sa/zentao/controller/CommonsController.java +++ b/src/main/java/com/sa/zentao/controller/CommonsController.java @@ -19,6 +19,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.poi.ss.usermodel.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -106,6 +107,7 @@ public class CommonsController { f.setTitle(title); f.setSize(bytes.length); f.setPathname(baseUrl+"/zentao/img/"+fName); + f.setUrl("/zentao/img/"+fName); // url 存相对路径:经前端源/代理可达,避免绝对 baseUrl 在本地环境不可达(与 uploadBind 一致) f.setObjectid(file.getObjectId()); 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 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") public Result copyFile( UploadDTO file){ String files = file.getFiles(); @@ -190,9 +263,11 @@ public class CommonsController { String substring = pathname.substring(pathname.lastIndexOf("/")+1, pathname.length()); try { - File f = new File(linuxFilePath+"/"+substring); -// File f = new File("D:\\1.bmp"); - InputStream fis = new BufferedInputStream(new FileInputStream(linuxFilePath+"/"+substring)); + // 与上传保持一致的 OS 路径选择(原硬编码 linuxFilePath 导致 Windows 本地下载全部失败) + String osName = System.getProperty("os.name"); + 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")); byte[] buffer = new byte[fis.available()]; diff --git a/src/main/java/com/sa/zentao/controller/ZtCountController.java b/src/main/java/com/sa/zentao/controller/ZtCountController.java index 7e14cf9..17a0ed4 100644 --- a/src/main/java/com/sa/zentao/controller/ZtCountController.java +++ b/src/main/java/com/sa/zentao/controller/ZtCountController.java @@ -4,18 +4,22 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.sa.zentao.dao.*; import com.sa.zentao.entity.ZtProject; import com.sa.zentao.qo.ZtCountQo; +import com.sa.zentao.qo.ZtDocCheckQo; import com.sa.zentao.qo.ZtProjectQo; +import com.sa.zentao.service.IZtDocCheckService; import com.sa.zentao.service.IZtProjectService; import com.sa.zentao.service.impl.IZtCountService; import com.sa.zentao.utils.DateUtils; import jakarta.servlet.ServletOutputStream; import jakarta.servlet.http.HttpServletResponse; import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.CollectionUtils; 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; @@ -35,6 +39,9 @@ public class ZtCountController { @Autowired private IZtProjectService projectService; + @Autowired + private IZtDocCheckService docCheckService; + /** * 开发看到的 @@ -205,4 +212,48 @@ public class ZtCountController { 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(); + } + } diff --git a/src/main/java/com/sa/zentao/controller/ZtPerfController.java b/src/main/java/com/sa/zentao/controller/ZtPerfController.java new file mode 100644 index 0000000..1846c1a --- /dev/null +++ b/src/main/java/com/sa/zentao/controller/ZtPerfController.java @@ -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; + +/** + *

+ * 绩效报表(三期批2:报表引擎+接口) 前端控制器 + *

+ * + * @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 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> configList(@RequestParam(required = false) String role) { + QueryWrapper 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("仅管理员可维护绩效配置"); + } + } +} diff --git a/src/main/java/com/sa/zentao/controller/ZtStoryExpandController.java b/src/main/java/com/sa/zentao/controller/ZtStoryExpandController.java index dc788dc..6ba7636 100644 --- a/src/main/java/com/sa/zentao/controller/ZtStoryExpandController.java +++ b/src/main/java/com/sa/zentao/controller/ZtStoryExpandController.java @@ -3,6 +3,7 @@ package com.sa.zentao.controller; import com.sa.zentao.dao.Result; import com.sa.zentao.entity.ZtStoryExpand; import com.sa.zentao.service.IZtStoryExpandService; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -49,6 +50,20 @@ public class ZtStoryExpandController { 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 queryByStoryId(Integer storyId) { + if (storyId == null) { + return Result.fail("storyId不能为空"); + } + return Result.success(ztStoryExpandService.getOne( + new QueryWrapper().lambda().eq(ZtStoryExpand::getStoryId, storyId))); + } + /** * 保存或更新(有数据了更新不新增) * @param entity 需求扩展信息 diff --git a/src/main/java/com/sa/zentao/controller/ZtTaskController.java b/src/main/java/com/sa/zentao/controller/ZtTaskController.java index aa23144..4cdcd36 100644 --- a/src/main/java/com/sa/zentao/controller/ZtTaskController.java +++ b/src/main/java/com/sa/zentao/controller/ZtTaskController.java @@ -100,6 +100,16 @@ public class ZtTaskController { 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") public Result approval(@RequestBody ZtTaskDTO dto){ ztTaskService.approval(dto); diff --git a/src/main/java/com/sa/zentao/controller/ZtUserController.java b/src/main/java/com/sa/zentao/controller/ZtUserController.java index dfcc160..f297afc 100644 --- a/src/main/java/com/sa/zentao/controller/ZtUserController.java +++ b/src/main/java/com/sa/zentao/controller/ZtUserController.java @@ -139,6 +139,8 @@ public class ZtUserController { .eq(ZtUser::getAccount,RiskUserThreadLocal.get().getName())); } 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)); } diff --git a/src/main/java/com/sa/zentao/dao/PerformanceDTO.java b/src/main/java/com/sa/zentao/dao/PerformanceDTO.java index 13411e5..573eb51 100644 --- a/src/main/java/com/sa/zentao/dao/PerformanceDTO.java +++ b/src/main/java/com/sa/zentao/dao/PerformanceDTO.java @@ -182,4 +182,31 @@ public class PerformanceDTO implements Serializable { private BigDecimal productPlanStoryNum; //本阶段准时发布研发数量 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 perfRawDetail; + /** ===== CHG-044 达标工时全链路(弹窗「绩效数据」列展示分子分母) ===== */ + //团队总工作天数(人·天,=工作天数×KFZ 人数 − 团队请假折算天数) + private BigDecimal teamWorkDays; + //团队请假折算天数(团队请假小时 ÷8) + private BigDecimal teamLeaveDays; + //团队达标总工时(=团队总工作天数 ×5) + private BigDecimal teamTargetTime; } diff --git a/src/main/java/com/sa/zentao/dao/UploadDTO.java b/src/main/java/com/sa/zentao/dao/UploadDTO.java index 4cf659b..ddf8bc6 100644 --- a/src/main/java/com/sa/zentao/dao/UploadDTO.java +++ b/src/main/java/com/sa/zentao/dao/UploadDTO.java @@ -26,6 +26,20 @@ public class UploadDTO implements Serializable { private Integer objectId; + //代码审查结果 pass/reject(objectType=aiCodeReview 时可选传入) + private String reviewResult; + private List testIds; + /** + * uploadBind 入口归一化(CHG-077):story 一律按 storyPrd 处理。 + * uploadBind 仅 AI 通道使用(UI 手动附件走两段式上传不进此接口), + * 归一后文件进详情页「需求文档」区块并刷新 prd_url,不再落通用附件区。 + */ + public void normalizeObjectTypeForBind() { + if ("story".equals(this.objectType)) { + this.objectType = "storyPrd"; + } + } + } diff --git a/src/main/java/com/sa/zentao/dao/ZtStoryDTO.java b/src/main/java/com/sa/zentao/dao/ZtStoryDTO.java index 527c55f..c7e57a0 100644 --- a/src/main/java/com/sa/zentao/dao/ZtStoryDTO.java +++ b/src/main/java/com/sa/zentao/dao/ZtStoryDTO.java @@ -378,4 +378,22 @@ public class ZtStoryDTO implements Serializable { */ @ExcelIgnore 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; } diff --git a/src/main/java/com/sa/zentao/dao/ZtStoryUserDTO.java b/src/main/java/com/sa/zentao/dao/ZtStoryUserDTO.java index 715f30c..96e3aaf 100644 --- a/src/main/java/com/sa/zentao/dao/ZtStoryUserDTO.java +++ b/src/main/java/com/sa/zentao/dao/ZtStoryUserDTO.java @@ -57,41 +57,46 @@ public class ZtStoryUserDTO implements Serializable { private String storyList; @ExcelIgnore private Set execList; - @ExcelProperty(value = "状态",index =5) + /** + * 迭代版本(CHG-078):execList 中迭代名称去重排序逗号拼接,仅供导出/列表展示 + */ + @ExcelProperty(value = "迭代版本", index = 5) + private String execNames; + @ExcelProperty(value = "状态",index =6) private String status; - @ExcelProperty(value = "来源",index =6) + @ExcelProperty(value = "来源",index =7) private String source; - @ExcelProperty(value = "期望完成时间",index =7) + @ExcelProperty(value = "期望完成时间",index =8) private Date planEndDate; /** * 验收人 */ @ExcelIgnore private String ysUser; - @ExcelProperty(value = "验收人",index =8) + @ExcelProperty(value = "验收人",index =9) private String ysUserName; - @ExcelProperty(value = "评审人",index =9) + @ExcelProperty(value = "评审人",index =10) private String reviewedbyName; - @ExcelProperty(value = "创建日期",index =10) + @ExcelProperty(value = "创建日期",index =11) private Date openeddate; - @ExcelProperty(value = "更新日期",index =11) + @ExcelProperty(value = "更新日期",index =12) private Date lastediteddate; @ExcelIgnore private String closedby; - @ExcelProperty(value = "由谁关闭",index =12) + @ExcelProperty(value = "由谁关闭",index =13) private String closedbyName; @ExcelIgnore private Date closeddate; - @ExcelProperty(value = "关闭原因",index =13) + @ExcelProperty(value = "关闭原因",index =14) private String closedreason; - @ExcelProperty(value = "需求链接",index =14) + @ExcelProperty(value = "需求链接",index =15) private WriteCellData url; @ExcelIgnore private String vision; diff --git a/src/main/java/com/sa/zentao/dao/ZtTaskAiBatchDTO.java b/src/main/java/com/sa/zentao/dao/ZtTaskAiBatchDTO.java new file mode 100644 index 0000000..4ff5f37 --- /dev/null +++ b/src/main/java/com/sa/zentao/dao/ZtTaskAiBatchDTO.java @@ -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 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; + } +} diff --git a/src/main/java/com/sa/zentao/dao/ZtUserDTO.java b/src/main/java/com/sa/zentao/dao/ZtUserDTO.java index 6382ae9..4f8ad3d 100644 --- a/src/main/java/com/sa/zentao/dao/ZtUserDTO.java +++ b/src/main/java/com/sa/zentao/dao/ZtUserDTO.java @@ -128,6 +128,9 @@ public class ZtUserDTO implements Serializable { private UserType userType; + //开发方向(CHG-038,仅 KFZ):frontend=前端 / backend=后端 + private String devDirection; + private String userTypeValue; private String roleTypeValue; diff --git a/src/main/java/com/sa/zentao/entity/ZtDocCheck.java b/src/main/java/com/sa/zentao/entity/ZtDocCheck.java new file mode 100644 index 0000000..2a30c47 --- /dev/null +++ b/src/main/java/com/sa/zentao/entity/ZtDocCheck.java @@ -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; + +/** + *

+ * 大型需求文档齐备核查快照表(FR-014 三期) + * 一行 = 某大型需求某月某类文档的核查结果; + * 状态流 pending(待确认)→ appealing(申诉中)→ resolved(复核完结,appealResult=pass 时回滚扣分) + *

+ * + * @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)); + } + +} diff --git a/src/main/java/com/sa/zentao/entity/ZtFile.java b/src/main/java/com/sa/zentao/entity/ZtFile.java index 5ec43a4..17b7b3c 100644 --- a/src/main/java/com/sa/zentao/entity/ZtFile.java +++ b/src/main/java/com/sa/zentao/entity/ZtFile.java @@ -29,6 +29,9 @@ public class ZtFile implements Serializable { private String pathname; + //附件访问链接 + private String url; + //文件名 private String title; //后缀 diff --git a/src/main/java/com/sa/zentao/entity/ZtMeeting.java b/src/main/java/com/sa/zentao/entity/ZtMeeting.java index b2e9045..7f18e99 100644 --- a/src/main/java/com/sa/zentao/entity/ZtMeeting.java +++ b/src/main/java/com/sa/zentao/entity/ZtMeeting.java @@ -73,4 +73,9 @@ public class ZtMeeting implements Serializable { private String result; private String meetingAfter; + /** + * 会议纪要MD访问链接(最新一份) + */ + private String url; + } diff --git a/src/main/java/com/sa/zentao/entity/ZtPerfConfig.java b/src/main/java/com/sa/zentao/entity/ZtPerfConfig.java new file mode 100644 index 0000000..63ae16c --- /dev/null +++ b/src/main/java/com/sa/zentao/entity/ZtPerfConfig.java @@ -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; + +/** + *

+ * 绩效指标规则配置表(三期,prd §7.5 九岗位算分模型) + * 一行 = 某岗位某指标项;rule_json 存规则参数(type: linear/threshold/manual) + *

+ * + * @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; + + +} diff --git a/src/main/java/com/sa/zentao/entity/ZtStory.java b/src/main/java/com/sa/zentao/entity/ZtStory.java index effffcf..87d99e3 100644 --- a/src/main/java/com/sa/zentao/entity/ZtStory.java +++ b/src/main/java/com/sa/zentao/entity/ZtStory.java @@ -240,4 +240,49 @@ public class ZtStory implements Serializable { * 测试人员 */ 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; } diff --git a/src/main/java/com/sa/zentao/entity/ZtStoryExpand.java b/src/main/java/com/sa/zentao/entity/ZtStoryExpand.java index a10da64..fb242af 100644 --- a/src/main/java/com/sa/zentao/entity/ZtStoryExpand.java +++ b/src/main/java/com/sa/zentao/entity/ZtStoryExpand.java @@ -48,6 +48,11 @@ public class ZtStoryExpand implements Serializable { */ private String aiEfficiencyCoefficient; + /** + * AI参与率(只存不算,口径待定) + */ + private String aiParticipationRate; + /** * 评估工时 */ @@ -115,4 +120,10 @@ public class ZtStoryExpand implements Serializable { */ private String testPerson; + /** + * AI框架验收指标(Given/When/Then,MD 文本;框架经 saveOrUpdate 上传, + * 与老验收标准 zt_storyspec.verify 互不干扰——CHG-022) + */ + private String acceptanceCriteria; + } diff --git a/src/main/java/com/sa/zentao/entity/ZtUser.java b/src/main/java/com/sa/zentao/entity/ZtUser.java index 417949b..ae5ea8e 100644 --- a/src/main/java/com/sa/zentao/entity/ZtUser.java +++ b/src/main/java/com/sa/zentao/entity/ZtUser.java @@ -126,6 +126,9 @@ public class ZtUser implements Serializable { private UserType userType; + //开发方向(CHG-038,仅 user_type=KFZ 有效):frontend=前端 / backend=后端,NULL 按后端口径 + private String devDirection; + private String productIds; @TableField(exist = false) diff --git a/src/main/java/com/sa/zentao/enums/FileTypes.java b/src/main/java/com/sa/zentao/enums/FileTypes.java index 673eb30..53a3674 100644 --- a/src/main/java/com/sa/zentao/enums/FileTypes.java +++ b/src/main/java/com/sa/zentao/enums/FileTypes.java @@ -13,6 +13,14 @@ public enum FileTypes { feedbackStory("feedbackStory", "问题反馈"), ywTask("ywTask", "运维任务"), meeting("meeting", "会议"), + aiCodeReview("aiCodeReview", "代码审查报告"), + aiWorkLog("aiWorkLog", "工作日志"), + aiDocUpdate("aiDocUpdate", "AI项目文档更新记录"), + storyPrd("storyPrd", "需求文档"), + testCase("testCase", "测试用例"), + testReport("testReport", "测试报告模版"), + testReportSubmit("testReportSubmit", "测试报告提交"), + testOther("testOther", "其他测试文档"), ; @EnumValue diff --git a/src/main/java/com/sa/zentao/mapper/ZtDocCheckMapper.java b/src/main/java/com/sa/zentao/mapper/ZtDocCheckMapper.java new file mode 100644 index 0000000..87c61f0 --- /dev/null +++ b/src/main/java/com/sa/zentao/mapper/ZtDocCheckMapper.java @@ -0,0 +1,16 @@ +package com.sa.zentao.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sa.zentao.entity.ZtDocCheck; + +/** + *

+ * Mapper 接口 + *

+ * + * @author ai + * @since 2026-07-28 + */ +public interface ZtDocCheckMapper extends BaseMapper { + +} diff --git a/src/main/java/com/sa/zentao/mapper/ZtPerfConfigMapper.java b/src/main/java/com/sa/zentao/mapper/ZtPerfConfigMapper.java new file mode 100644 index 0000000..5cf822c --- /dev/null +++ b/src/main/java/com/sa/zentao/mapper/ZtPerfConfigMapper.java @@ -0,0 +1,16 @@ +package com.sa.zentao.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sa.zentao.entity.ZtPerfConfig; + +/** + *

+ * Mapper 接口 + *

+ * + * @author ai + * @since 2026-07-28 + */ +public interface ZtPerfConfigMapper extends BaseMapper { + +} diff --git a/src/main/java/com/sa/zentao/mapper/ZtTaskMapper.java b/src/main/java/com/sa/zentao/mapper/ZtTaskMapper.java index 9c033aa..11988f8 100644 --- a/src/main/java/com/sa/zentao/mapper/ZtTaskMapper.java +++ b/src/main/java/com/sa/zentao/mapper/ZtTaskMapper.java @@ -28,6 +28,12 @@ public interface ZtTaskMapper extends BaseMapper { List itApprovalByUserName(@Param("name") String name, @Param("startDate") Date firstDayOfMonth, @Param("endDate") Date lastDayOfMonth); + /** + * 按姓名批量查询请假审批(条件与 itApprovalByUserName 完全一致,仅 name 改为 IN 批量; + * 供 storyBarChart 月度循环批量化使用,老方法保留不动) + */ + List itApprovalsByNames(@Param("names") List names, @Param("startDate") Date firstDayOfMonth, @Param("endDate") Date lastDayOfMonth); + List taskListByIds(@Param("ids") List ids); List taskListByStoryIds(@Param("ids") List sIds); diff --git a/src/main/java/com/sa/zentao/qo/ZtDocCheckQo.java b/src/main/java/com/sa/zentao/qo/ZtDocCheckQo.java new file mode 100644 index 0000000..e04b58e --- /dev/null +++ b/src/main/java/com/sa/zentao/qo/ZtDocCheckQo.java @@ -0,0 +1,52 @@ +package com.sa.zentao.qo; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; + +/** + *

+ * 文档齐备核查入参(FR-014 三期):generateDocCheck 用 month; + * docCheckAppeal 用 storyId/month/docType/reason;docCheckAppealResolve 另加 pass/remark + *

+ * + * @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; +} diff --git a/src/main/java/com/sa/zentao/qo/ZtPerfQo.java b/src/main/java/com/sa/zentao/qo/ZtPerfQo.java new file mode 100644 index 0000000..ffdf6da --- /dev/null +++ b/src/main/java/com/sa/zentao/qo/ZtPerfQo.java @@ -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; +} diff --git a/src/main/java/com/sa/zentao/service/IZtDocCheckService.java b/src/main/java/com/sa/zentao/service/IZtDocCheckService.java new file mode 100644 index 0000000..9f705e4 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/IZtDocCheckService.java @@ -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; + +/** + *

+ * 大型需求文档齐备核查 服务类(FR-014 三期) + *

+ * + * @author ai + * @since 2026-07-28 + */ +public interface IZtDocCheckService extends IService { + + /** + * 生成某月文档齐备快照:大型需求(workload_index>20 且当月有完成度变化)× 五类文档, + * 结果写 zt_doc_check,并按 缺失数×2 合并写 zt_month_score.scopeJson 的 docCheck 节点 + * @param month 考核月份 yyyy-MM + * @return 摘要 {month, storyCount, checkRows, deductByAccount} + */ + Map 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 listMonthRows(String month); + + /** + * 文档齐备矩阵页数据(按需求聚合:docs 五类判定/扣分/状态/申诉状态) + */ + List> listMatrix(String month); + + /** + * 月度确认:该月快照全部标记 CONFIRMED(项目经理确认后锁定) + */ + void confirmMonth(String month); +} diff --git a/src/main/java/com/sa/zentao/service/IZtFileService.java b/src/main/java/com/sa/zentao/service/IZtFileService.java index ba49ddb..956a5a2 100644 --- a/src/main/java/com/sa/zentao/service/IZtFileService.java +++ b/src/main/java/com/sa/zentao/service/IZtFileService.java @@ -16,4 +16,16 @@ public interface IZtFileService extends IService { 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); } diff --git a/src/main/java/com/sa/zentao/service/IZtPerfConfigService.java b/src/main/java/com/sa/zentao/service/IZtPerfConfigService.java new file mode 100644 index 0000000..906b53d --- /dev/null +++ b/src/main/java/com/sa/zentao/service/IZtPerfConfigService.java @@ -0,0 +1,30 @@ +package com.sa.zentao.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.sa.zentao.entity.ZtPerfConfig; + +/** + *

+ * 绩效指标规则配置 服务类(三期) + *

+ * + * @author ai + * @since 2026-07-28 + */ +public interface IZtPerfConfigService extends IService { + + /** + * 取岗位某指标的启用配置;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); +} diff --git a/src/main/java/com/sa/zentao/service/IZtPerfReportService.java b/src/main/java/com/sa/zentao/service/IZtPerfReportService.java new file mode 100644 index 0000000..f511eb4 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/IZtPerfReportService.java @@ -0,0 +1,40 @@ +package com.sa.zentao.service; + +import com.sa.zentao.qo.ZtPerfQo; +import com.sa.zentao.service.perf.PerfMonthScope; + +/** + *

+ * 绩效报表(三期批2:报表引擎+接口) 服务类 + *

+ * + * @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); +} diff --git a/src/main/java/com/sa/zentao/service/IZtTaskService.java b/src/main/java/com/sa/zentao/service/IZtTaskService.java index 23e62bc..21c29ef 100644 --- a/src/main/java/com/sa/zentao/service/IZtTaskService.java +++ b/src/main/java/com/sa/zentao/service/IZtTaskService.java @@ -2,6 +2,7 @@ package com.sa.zentao.service; import com.github.pagehelper.PageInfo; import com.sa.zentao.dao.ItApproval; +import com.sa.zentao.dao.ZtTaskAiBatchDTO; import com.sa.zentao.dao.ZtTaskDTO; import com.sa.zentao.entity.ZtTask; import com.baomidou.mybatisplus.extension.service.IService; @@ -9,6 +10,7 @@ import com.sa.zentao.qo.ZtProjectQo; import java.util.Date; import java.util.List; +import java.util.Map; /** *

@@ -38,6 +40,14 @@ public interface IZtTaskService extends IService { 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 aiBatchAdd(ZtTaskAiBatchDTO dto); + void approval(ZtTaskDTO dto); PageInfo myTaskPageList(ZtProjectQo qo); @@ -56,6 +66,12 @@ public interface IZtTaskService extends IService { List itApprovalByUserName(String s, Date firstDayOfMonth, Date lastDayOfMonth); + /** + * 按姓名批量查询请假审批(条件与 itApprovalByUserName 一致,name 改 IN; + * 供 storyBarChart 月度循环批量化,老方法保留不动) + */ + List itApprovalsByNames(List names, Date firstDayOfMonth, Date lastDayOfMonth); + /** * * @param firstDayOfMonth 开始 diff --git a/src/main/java/com/sa/zentao/service/impl/IZtCountService.java b/src/main/java/com/sa/zentao/service/impl/IZtCountService.java index 72847ad..24c9b55 100644 --- a/src/main/java/com/sa/zentao/service/impl/IZtCountService.java +++ b/src/main/java/com/sa/zentao/service/impl/IZtCountService.java @@ -25,9 +25,11 @@ import com.sa.zentao.qo.ZtCountQo; import com.sa.zentao.qo.ZtProjectQo; import com.sa.zentao.qo.ZtUserQo; import com.sa.zentao.service.*; +import com.sa.zentao.service.perf.FixedTeamMonthTime; import com.sa.zentao.utils.BigDecimalUtils; import com.sa.zentao.utils.DateUtils; import com.sa.zentao.utils.ExcelUtil; +import com.sa.zentao.utils.PerfScoreRules; import com.sa.zentao.utils.SFunctionColums; import com.tencentcloudapi.ses.v20201002.models.EmailSender; import jakarta.servlet.ServletOutputStream; @@ -513,6 +515,114 @@ public class IZtCountService { @Autowired private IZtUserService userService; + @Autowired + private IZtPerfReportService perfReportService; + + /** + * CHG-037 从新绩效引擎(zt_perf_config 规则)取某岗位某指标的加权得分(score×weight)。 + * 指标无数据/人工项(score=null)或未启用时返回 defaultValue(人工项传满分默认值)。 + */ + private BigDecimal perfItemScore(com.sa.zentao.service.perf.PerfMonthScope scope, String itemKey, BigDecimal defaultValue) { + if (scope == null || scope.getItems() == null) { + return defaultValue; + } + for (com.sa.zentao.service.perf.PerfMonthScope.Item item : scope.getItems()) { + if (itemKey.equals(item.getItemKey()) && item.getScore() != null) { + double w = item.getWeight() == null ? 0D : item.getWeight(); + BigDecimal v = BigDecimal.valueOf(item.getScore() * w).setScale(2, BigDecimal.ROUND_HALF_UP); + return new BigDecimal(v.stripTrailingZeros().toPlainString()); + } + } + return defaultValue; + } + + /** + * CHG-071 月度快照解析兼容三种格式(exportScope 用): + * ①引擎 PerfMonthScope(items[]+role):新算 DTO 为底(含全部统计字段与新口径自动分),人工项(IT 经理手改)以快照为准覆盖; + * ②老 DTO 平铺格式(含 account):直接反序列化(人工分本就在内),统计字段缺失从新算回填; + * ③docCheck 专项等其他格式:与绩效考核行无关,直接用新算 DTO。 + */ + private PerformanceDTO resolveScoreDto(String scopeJson, PerformanceDTO fresh) { + if (StringUtils.isBlank(scopeJson) || fresh == null) { + return fresh; + } + com.alibaba.fastjson.JSONObject obj; + try { + obj = JSON.parseObject(scopeJson); + } catch (Exception e) { + return fresh; + } + if (obj.containsKey("items") && obj.containsKey("role")) { + overlayManualScores(fresh, obj.toJavaObject(com.sa.zentao.service.perf.PerfMonthScope.class)); + return fresh; + } + if (obj.containsKey("account")) { + PerformanceDTO snap = obj.toJavaObject(PerformanceDTO.class); + backfillStats(snap, fresh); + return snap; + } + return fresh; + } + + /** 老 DTO 快照缺统计字段时从新算回填(导出 Excel 模板强依赖这些字段,缺则 NPE) */ + private void backfillStats(PerformanceDTO target, PerformanceDTO fresh) { + if (target.getTotalTask() == null) target.setTotalTask(fresh.getTotalTask()); + if (target.getDelayTask() == null) target.setDelayTask(fresh.getDelayTask()); + if (target.getFinishPunctuality() == null) target.setFinishPunctuality(fresh.getFinishPunctuality()); + if (target.getWorkTime() == null) target.setWorkTime(fresh.getWorkTime()); + if (target.getSaturation() == null) target.setSaturation(fresh.getSaturation()); + if (target.getExamineTime() == null) target.setExamineTime(fresh.getExamineTime()); + if (target.getAllocationTime() == null) target.setAllocationTime(fresh.getAllocationTime()); + if (target.getBugCount() == null) target.setBugCount(fresh.getBugCount()); + if (target.getBugDensity() == null) target.setBugDensity(fresh.getBugDensity()); + if (target.getDays() == null) target.setDays(fresh.getDays()); + if (target.getApprovalDays() == null) target.setApprovalDays(fresh.getApprovalDays()); + if (target.getTotalTime() == null) target.setTotalTime(fresh.getTotalTime()); + } + + /** 引擎格式快照:source=manual 的得分项(IT 经理手改的分)按 score×weight 覆盖到 DTO 对应字段 */ + private void overlayManualScores(PerformanceDTO dto, com.sa.zentao.service.perf.PerfMonthScope scope) { + if (scope == null || scope.getItems() == null) { + return; + } + for (com.sa.zentao.service.perf.PerfMonthScope.Item item : scope.getItems()) { + if (!"manual".equals(item.getSource()) || item.getScore() == null) { + continue; + } + double w = item.getWeight() == null ? 0D : item.getWeight(); + java.math.BigDecimal v = new java.math.BigDecimal( + java.math.BigDecimal.valueOf(item.getScore() * w).stripTrailingZeros().toPlainString()); + switch (item.getItemKey() == null ? "" : item.getItemKey()) { + case "systemStability": dto.setSystemStabilityScore(v); break; + case "skillImprovement": dto.setProfessionalSkillEnhancementScore(v); break; + case "problemManagement": dto.setDevelopFeedbackStory(v); break; + case "otherOps": dto.setOtherOpsScore(v); break; + case "workAttitude": dto.setWorkAttitude(v); break; + case "problemResponse": dto.setProductProblemResponse(v); break; + case "responsibility": dto.setProductResponsibilityScore(v); break; + case "acceptOnTime": dto.setReleaseScore(v); break; + case "designQuality": dto.setDesignScore(v); break; + default: break; + } + } + } + + /** + * CHG-041 把 scope 各指标的过程值明细(分子/分母/率)拷入 DTO,弹窗「绩效数据」列展示 + */ + private void fillRawDetail(PerformanceDTO dto, com.sa.zentao.service.perf.PerfMonthScope scope) { + if (scope == null || scope.getItems() == null) { + return; + } + java.util.Map map = new java.util.HashMap<>(); + for (com.sa.zentao.service.perf.PerfMonthScope.Item item : scope.getItems()) { + if (item.getRawDetail() != null) { + map.put(item.getItemKey(), item.getRawDetail()); + } + } + dto.setPerfRawDetail(map); + } + @Autowired private IZtTeamService teamService; @@ -548,10 +658,10 @@ public class IZtCountService { PerformanceDTO performanceDTO = perList.get(i); ZtUser ztUser = userMap.get(performanceDTO.getAccount()); ZtMonthScore ztMonthScore = this.monthScoreService.getOne(new QueryWrapper().lambda().eq(ZtMonthScore::getDateStr, sDate).eq(ZtMonthScore::getAccount, performanceDTO.getAccount())); - if(ztMonthScore==null){ - continue; + //CHG-075:无快照人员不再跳过——用新算 DTO(自动分+人工满分默认)导出;有快照走三格式兼容解析 + if(ztMonthScore!=null){ + performanceDTO=resolveScoreDto(ztMonthScore.getScopeJson(),performanceDTO); } - performanceDTO=JSON.parseObject(ztMonthScore.getScopeJson(),PerformanceDTO.class); if (ztUser.getAccount().equals("weidongxia")) { generatorCPJLExcel(qo, performanceDTO.getUserName(), performanceDTO, qo.getDate(), perList); list.add(performanceDTO.getUserName() + "产品经理考核.xlsx"); @@ -580,6 +690,9 @@ public class IZtCountService { }else if (ztUser.getUserType() == UserType.CPJL ) { generatorCPJLExcel(qo, performanceDTO.getUserName(), performanceDTO, qo.getDate(), perList); list.add(performanceDTO.getUserName() + "产品经理考核.xlsx"); + }else if (ztUser.getUserType() == UserType.YW) { + generatorYwExcel(performanceDTO.getUserName(), performanceDTO, qo.getDate()); + list.add(performanceDTO.getUserName() + "运维.xlsx"); } } String dir = System.getProperty("user.dir") + "/"; @@ -628,8 +741,12 @@ public class IZtCountService { response.getOutputStream().close(); response.flushBuffer(); + } catch (BusinessException e) { + //CHG-075:业务异常交全局异常处理器回 JSON(前端提示原因),不再静默吞成空 200 + throw e; } catch (Exception e) { - log.error("", e); + log.error("exportScope 导出异常", e); + throw new BusinessException("导出失败"); } } @@ -750,9 +867,9 @@ public class IZtCountService { } else if ("liushengqing".equals(u.getAccount())) { result.add(buildUiScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d)); } else if (u.getUserType() == UserType.KFZ) { - result.add(buildKFZScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d)); + result.add(buildKFZScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d, accountIds)); } else if (u.getUserType() == UserType.CS) { - result.add(buildCScore(userMap,pids,u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d,releaseBugs.stream().filter(o->u.getAccount().equals(o.getBindUser())).collect(Collectors.toList()), cswcStoryList.stream().filter(o->u.getAccount().equals(o.getAssignedTo())).collect(Collectors.toList()))); + result.add(buildCScore(userMap,pids,u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d,releaseBugs.stream().filter(o->u.getAccount().equals(o.getBindUser())).collect(Collectors.toList()), testStoriesOf(u, cswcStoryList, pids, firstDayOfMonth, lastDayOfMonth))); } else if (u.getUserType() == UserType.UI) { result.add(buildUiScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d)); } else if (u.getUserType() == UserType.XMJL || u.getUserType() == UserType.XMGLY || u.getUserType() == UserType.GSGC) { @@ -830,9 +947,9 @@ public class IZtCountService { } else if ("liushengqing".equals(u.getAccount())) { result.add(buildUiScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d)); } else if (u.getUserType() == UserType.KFZ) { - result.add(buildKFZScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d)); + result.add(buildKFZScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d, accountIds)); } else if (u.getUserType() == UserType.CS) { - result.add(buildCScore(userMap,pids,u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d,releaseBugs.stream().filter(o->u.getAccount().equals(o.getBindUser())).collect(Collectors.toList()), cswcStoryList.stream().filter(o->u.getAccount().equals(o.getAssignedTo())).collect(Collectors.toList()))); + result.add(buildCScore(userMap,pids,u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d,releaseBugs.stream().filter(o->u.getAccount().equals(o.getBindUser())).collect(Collectors.toList()), testStoriesOf(u, cswcStoryList, pids, firstDayOfMonth, lastDayOfMonth))); } else if (u.getUserType() == UserType.UI) { result.add(buildUiScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d)); } else if (u.getUserType() == UserType.XMJL || u.getUserType() == UserType.XMGLY || u.getUserType() == UserType.GSGC) { @@ -845,6 +962,10 @@ public class IZtCountService { } //新的 PerformanceDTO buildKFZScore(ZtUser u, List approvalList, Date firstDayOfMonth, Date lastDayOfMonth, List taskList, Date d) { + return buildKFZScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d, null); + } + + PerformanceDTO buildKFZScore(ZtUser u, List approvalList, Date firstDayOfMonth, Date lastDayOfMonth, List taskList, Date d, List accountIds) { PerformanceDTO dto = new PerformanceDTO(); dto.setUserName(u.getNickname()); @@ -853,6 +974,12 @@ public class IZtCountService { dto = setUserWorkTime(dto, d, approvalList, firstDayOfMonth); + //团队口径月度达标工时(选中产品集在窗执行的 KFZ 成员;accountIds 为空时退回个人 0.75 口径) + //中间量(团队请假/团队总工作天数/团队达标总工时/人均达标工时)随 dto 透出供页面展示 + if (!CollectionUtils.isEmpty(accountIds)) { + fillTeamExamine(dto, accountIds, firstDayOfMonth, lastDayOfMonth, d); + } + //产出工时 算task List taskTimeList = this.taskService.getNormalTaskList(taskList.stream().filter(o -> u.getAccount().equals(o.getAssignedTo())).collect(Collectors.toList())); @@ -868,8 +995,8 @@ public class IZtCountService { dto.setWorkTime(floatBatchAdd(taskTimeList.stream().map(o -> o.getConsumed()).collect(Collectors.toList()))); - //工作饱和度 - dto.setSaturation(BigDecimalUtils.isZero(dto.getExamineTime()) ? BigDecimal.ZERO : dto.getAllocationTime().divide(dto.getExamineTime(), 2, BigDecimal.ROUND_UP)); + //工作饱和度(达标率=实际产出工时 consumed ÷ 月度达标工时) + dto.setSaturation(BigDecimalUtils.isZero(dto.getExamineTime()) ? BigDecimal.ZERO : dto.getWorkTime().divide(dto.getExamineTime(), 2, BigDecimal.ROUND_HALF_UP)); //完成准时率 TODO 延期 / 总完成 @@ -880,12 +1007,10 @@ public class IZtCountService { //准时完成率 BigDecimal onTimeFinishRate = BigDecimalUtils.isZero(finishAllTime) ? finishAllTime : onTime.divide(finishAllTime, 2, BigDecimal.ROUND_UP); dto.setFinishPunctuality(onTimeFinishRate); - /**准时率得分 - * 1. =100%,得满分; - * 2. <100%,每减少1%,扣3分,扣完截止。 + /**准时率得分(CHG-036 新 Excel 规则:=100%得25;95~100每减1%扣1分;≤94每减1%扣2分) */ dto.setPunctualityScore( - getKFZPunctualityScore(onTimeFinishRate) ); + PerfScoreRules.punctualityScore(onTimeFinishRate) ); //bug得分 @@ -895,35 +1020,30 @@ public class IZtCountService { int bugSize = bugList.size(); BigDecimal bugDensity = BigDecimalUtils.isZero(finishAllTime) ? finishAllTime : BigDecimal.valueOf(bugSize).divide(finishAllTime, 2, BigDecimal.ROUND_HALF_UP); dto.setBugDensity(bugDensity); - BigDecimal bugScore = BigDecimal.valueOf(30); - bugScore = BigDecimalUtils.isZero(finishAllTime) ? BigDecimal.ZERO : bugSize < 1 ? bugScore : - BigDecimal.valueOf(bugSize).divide(finishAllTime, 2, BigDecimal.ROUND_HALF_UP).multiply(BigDecimal.valueOf(100)).intValue() <= 15 ? bugScore : - BigDecimal.valueOf(bugSize).divide(finishAllTime, 2, BigDecimal.ROUND_HALF_UP).multiply(BigDecimal.valueOf(100)).intValue() > 25 ? BigDecimal.ZERO : - bugScore.subtract(BigDecimal.valueOf(bugSize).divide(finishAllTime, 2, BigDecimal.ROUND_HALF_UP).multiply(BigDecimal.valueOf(100)).subtract(BigDecimal.valueOf(15)).multiply(BigDecimal.valueOf(3))); + //Bug密度得分(CHG-036 新 Excel 规则:≤15%得30;>15%每增1%扣3分,扣完截止,无截断归零) + BigDecimal bugScore = BigDecimalUtils.isZero(finishAllTime) ? BigDecimal.ZERO + : PerfScoreRules.bugDensityScore(bugDensity.multiply(BigDecimal.valueOf(100))); dto.setBugCount(BigDecimal.valueOf(bugList.size())); dto.setBugScore(bugScore); - dto.setWorkAttitude(BigDecimal.valueOf(5)); - dto.setDocumentQualityScore(BigDecimal.valueOf(15)); - dto.setCodeQualityScore(BigDecimal.valueOf(5)); + //CHG-038 前端/后端工程师分流:前端表 饱和度满分30、无文档质量项、代码质量 flat 每问题扣3(人工项仍满分默认) + boolean frontendDev = "frontend".equals(u.getDevDirection()); + dto.setDevDirection(u.getDevDirection()); + //CHG-036 新 Excel 满分:不规范行为 5、代码质量 10、文档质量 10(人工评审项,默认满分;前端表无文档质量项→置空不计分) + dto.setWorkAttitude(PerfScoreRules.FULL_WORK_ATTITUDE); + if (!frontendDev) { + dto.setDocumentQualityScore(PerfScoreRules.FULL_DOCUMENT_QUALITY); + } + dto.setCodeQualityScore(PerfScoreRules.FULL_CODE_QUALITY); //工作质量 1. 全月Bug总数<6个 //2. 当月绩效考核成绩95分以上 //工作质量1. 全月Bug总数<6个 - //饱和率 分数 每天6小时 需求总工时 20分 一个扣2分 - if (BigDecimalUtils.isZero(dto.getExamineTime()) || BigDecimalUtils.isZero(dto.getAllocationTime())) { + //饱和度得分(CHG-036 新 Excel 规则:=100%满分;<100%每减1%扣2分,扣完截止;CHG-038 前端满分30/后端满分20) + if (BigDecimalUtils.isZero(dto.getExamineTime()) || BigDecimalUtils.isZero(dto.getWorkTime())) { dto.setSaturationScore(BigDecimal.ZERO); } else { - BigDecimal saturation = dto.getSaturation(); - int saturationInt = saturation.multiply(BigDecimal.valueOf(100)).intValue(); - BigDecimal saturationScore = BigDecimal.ZERO; - if (saturationInt >= 100) { - saturationScore = BigDecimal.valueOf(20); - } else if (saturationInt > 90) { - saturationScore = BigDecimal.valueOf(20 - (100 - saturationInt) * 2); - } else { - - } - dto.setSaturationScore(saturationScore); + dto.setSaturationScore(PerfScoreRules.saturationScore(dto.getSaturation(), + frontendDev ? PerfScoreRules.FULL_SATURATION_FRONT : PerfScoreRules.FULL_SATURATION)); } return dto; @@ -967,6 +1087,140 @@ public class IZtCountService { return dto; } + // 团队月度达标总工时写死配置见 FixedTeamMonthTime.MAP(产品名 → 团队月达标总工时,如 飞侠车服=525) + + /** + * 团队口径月度达标工时(选中产品集在窗执行的 KFZ 成员): + * = (工作天数×团队人数 − 团队请假小时÷8)×5 ÷ 团队人数;请假全团队平摊,每人相同。 + * 工作天数含法定节假日/调休(DateUtils.getWorkDaysInCurrentMonth)。 + * 同时把中间量(团队请假折算天数/团队总工作天数/团队达标总工时)写入 DTO 供页面展示。 + * 团队所属产品写死月达标总工时的(如飞侠车服=525),基数用写死值÷5折算成人天,请假照扣。 + * + * @param accountIds 选中产品集在窗执行的团队成员账号 + * @param firstDay 当月首日 + * @param lastDay 当月末日 + * @param d 月份 + */ + public BigDecimal teamExamineTime(List accountIds, Date firstDay, Date lastDay, Date d) { + return fillTeamExamine(null, accountIds, firstDay, lastDay, d); + } + + /** + * 团队成员所属产品写死的团队月达标总工时合计(如 飞侠车服=525),没有配置返回 null。 + * 口径:团队成员本月在窗执行 → zt_executionproject → zt_projectproduct → 产品名命中配置的累加。 + */ + private BigDecimal fixedTeamMonthTime(List accountIds, Date firstDay, Date lastDay) { + if (CollectionUtils.isEmpty(accountIds) || FixedTeamMonthTime.MAP.isEmpty()) { + return null; + } + List teams = this.teamService.list(new QueryWrapper().lambda() + .eq(ZtTeam::getType, "execution") + .in(ZtTeam::getAccount, accountIds)); + if (CollectionUtils.isEmpty(teams)) { + return null; + } + List executions = this.projectService.listByIds(teams.stream().map(ZtTeam::getRoot).distinct().collect(Collectors.toList())); + executions = executions.stream().filter(o -> o.getBegin() != null && o.getEnd() != null && ( + (o.getBegin().getTime() >= firstDay.getTime() && o.getBegin().getTime() <= lastDay.getTime()) + || (o.getEnd().getTime() >= firstDay.getTime() && o.getEnd().getTime() <= lastDay.getTime()) + )).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(executions)) { + return null; + } + List executionprojects = this.executionprojectService.list(new QueryWrapper().lambda() + .in(ZtExecutionproject::getExecution, executions.stream().map(ZtProject::getId).collect(Collectors.toList()))); + if (CollectionUtils.isEmpty(executionprojects)) { + return null; + } + List projectproducts = this.projectproductService.list(new QueryWrapper().lambda() + .in(ZtProjectproduct::getProject, executionprojects.stream().map(ZtExecutionproject::getProject).distinct().collect(Collectors.toList()))); + if (CollectionUtils.isEmpty(projectproducts)) { + return null; + } + List products = this.productService.listByIds(projectproducts.stream().map(ZtProjectproduct::getProduct).distinct().collect(Collectors.toList())); + BigDecimal total = BigDecimal.ZERO; + for (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; + } + + /** + * 计算团队口径月度达标工时,并在 dto 非空时把中间量一并写入(页面「绩效数据」列展示)。 + */ + private BigDecimal fillTeamExamine(PerformanceDTO dto, List accountIds, Date firstDay, Date lastDay, Date d) { + if (CollectionUtils.isEmpty(accountIds)) { + return BigDecimal.ZERO; + } + List devs = userService.list(new QueryWrapper().lambda() + .in(ZtUser::getAccount, accountIds) + .eq(ZtUser::getUserType, UserType.KFZ) + .ne(ZtUser::getDeleted, "1")); + int devCount = devs == null ? 0 : devs.size(); + if (devCount == 0) { + if (dto != null) { + dto.setExamineTime(BigDecimal.ZERO); + } + return BigDecimal.ZERO; + } + BigDecimal teamLeaveHours = BigDecimal.ZERO; + for (ZtUser dev : devs) { + List approvals = this.taskService.itApprovalByUserName(dev.getNickname(), firstDay, lastDay); + BigDecimal hours = getApprovalTime(approvals, d); + if (hours != null) { + teamLeaveHours = teamLeaveHours.add(hours); + } + } + int workDays = DateUtils.getWorkDaysInCurrentMonth(d); + // 团队总工作天数 = 工作天数×人数 − 团队请假折算天数(请假小时÷8);写死产品(如飞侠车服)基数用写死总工时÷5折算 + BigDecimal teamLeaveDays = teamLeaveHours.divide(BigDecimal.valueOf(8), 2, BigDecimal.ROUND_HALF_UP); + BigDecimal teamDays; + BigDecimal fixedTeamTime = fixedTeamMonthTime(accountIds, firstDay, lastDay); + if (fixedTeamTime != null) { + teamDays = fixedTeamTime.divide(BigDecimal.valueOf(5), 2, BigDecimal.ROUND_HALF_UP).subtract(teamLeaveDays); + } else { + teamDays = BigDecimal.valueOf((long) workDays * devCount).subtract(teamLeaveDays); + } + BigDecimal teamTargetTime = teamDays.multiply(BigDecimal.valueOf(5)); + BigDecimal perCapita = teamTargetTime.divide(BigDecimal.valueOf(devCount), 2, BigDecimal.ROUND_HALF_UP); + if (dto != null) { + dto.setTeamLeaveDays(teamLeaveDays); + dto.setTeamWorkDays(teamDays); + dto.setTeamTargetTime(teamTargetTime); + dto.setExamineTime(perCapita); + } + return perCapita; + } + + /** + * CHG-054 测试需求范围修正:assignedTo ∪ zt_story_expand.test_person 指定(该列存中文姓名,多人逗号分隔)。 + * 原仅 assignedTo,漏掉"指定该测试但指派给开发"的需求(孙颖 3 vs 24)。 + * + * @param u 测试账号(匹配昵称) + * @param assignedPool assignedTo 预取的需求池(产品+月份已过滤) + */ + private List testStoriesOf(ZtUser u, List assignedPool, List pids, Date firstDay, Date lastDay) { + List result = assignedPool == null ? new ArrayList<>() + : assignedPool.stream().filter(o -> u.getAccount().equals(o.getAssignedTo())).collect(Collectors.toList()); + Set exists = result.stream().map(ZtStory::getId).collect(Collectors.toSet()); + List expands = this.storyExpandService.list(new QueryWrapper().lambda() + .like(ZtStoryExpand::getTestPerson, u.getNickname())); + List ids = expands.stream().map(ZtStoryExpand::getStoryId).filter(o -> !exists.contains(o)).collect(Collectors.toList()); + if (!ids.isEmpty()) { + result.addAll(this.storyService.list(new QueryWrapper().lambda() + .in(ZtStory::getId, ids) + .in(ZtStory::getProduct, pids) + .ge(ZtStory::getReleaseddate, firstDay).le(ZtStory::getReleaseddate, lastDay))); + } + return result; + } + + @Autowired + private com.sa.zentao.service.IZtStoryExpandService storyExpandService; + PerformanceDTO buildCScore( Map userMap,List pids,ZtUser u, List approvalList, Date firstDayOfMonth, Date lastDayOfMonth, List taskList, Date d,List releaseBugs,List testedStory) { PerformanceDTO dto = new PerformanceDTO(); @@ -1022,14 +1276,32 @@ public class IZtCountService { dto.setSeriousBug(BigDecimal.valueOf(seriousCount)); dto.setSlightBug(BigDecimal.valueOf(slightCount)); dto.setBugScore(BigDecimal.valueOf(bugScore)); - dto.setDocumentQualityScore(BigDecimal.valueOf(25)); + //CHG-057 测试文档齐备改实时字段判定(用户拍板):范围=test_person∪assignedTo 本月发布的需求(testedStory), + //判定=test_case_url/test_report_submit_url 非空(提交件,AI 模版 testReportDownload 不计); + //每缺一份扣 3 分(25 分项直接扣,扣完截止);本月无需求→满分 25 + int docMissing = 0; + if (!CollectionUtils.isEmpty(testedStory)) { + for (ZtStory s : testedStory) { + if (StringUtils.isEmpty(s.getTestCaseUrl())) { + docMissing++; + } + if (StringUtils.isEmpty(s.getTestReportSubmitUrl())) { + docMissing++; + } + } + } + dto.setDocumentQualityProblem(BigDecimal.valueOf(docMissing)); + dto.setDocumentQualityScore(BigDecimal.valueOf(Math.max(25 - docMissing * 3, 0))); dto.setWorkAttitude(BigDecimal.valueOf(5)); return dto; } private PerformanceDTO getBugFindScore(PerformanceDTO dto,ZtUser u,Date firstDayOfMonth,Date lastDayOfMonth,List testedStory) { + //CHG-055 得分不能为空:无测试需求/无开发工时/检出率为 0,统一显式给 0 分(原不赋值显示空) if(CollectionUtils.isEmpty(testedStory)){ + dto.setBugFindRate(BigDecimal.ZERO); + dto.setBugFindScore(BigDecimal.ZERO); return dto; } @@ -1038,6 +1310,8 @@ public class IZtCountService { .notIn(ZtTask::getStatus,"cancel","closed") .in(ZtTask::getStory, testedStory.stream().map(o -> o.getId()).collect(Collectors.toList()))); if(CollectionUtils.isEmpty(taskList)){ + dto.setBugFindRate(BigDecimal.ZERO); + dto.setBugFindScore(BigDecimal.ZERO); return dto; } List allBugList = this.bugService.list(new QueryWrapper().lambda().select(SFunctionColums.bugColumes()) @@ -1063,7 +1337,8 @@ public class IZtCountService { int i = bugRate.multiply(BigDecimal.valueOf(100)).intValue(); dto.setBugFindRate(bugRate); if(i<=0){ - + //CHG-055 检出率为 0 显式给 0 分(原不赋值显示空) + dto.setBugFindScore(BigDecimal.ZERO); }else if(i>20){ dto.setBugFindScore(BigDecimal.valueOf(30)); }else{ @@ -1089,8 +1364,8 @@ public class IZtCountService { CollectionUtils.isEmpty(onTimeTask) ? BigDecimal.ZERO : (BigDecimal.valueOf(onTimeTask.size()).divide(BigDecimal.valueOf(myTaskList.size()), 2, BigDecimal.ROUND_HALF_UP)); - int iOnTime = onTimeRate.multiply(BigDecimal.valueOf(100)).intValue(); - dto.setPunctualityScore(BigDecimal.valueOf(iOnTime >= 100 ? 50 : (iOnTime > 90) ? 40 : 0)); + //CHG-065 及时率得分走 PerfScoreRules.uiPunctualityScore(新 Excel 分段:=100%得50/≥90%得40/<90%得0;修复老内联 90 边界漏判) + dto.setPunctualityScore(com.sa.zentao.utils.PerfScoreRules.uiPunctualityScore(onTimeRate)); dto.setTotalTask(BigDecimal.valueOf(myTaskList.size())); dto.setDelayTask(BigDecimal.valueOf(delayTask.size())); dto.setFinishPunctuality(onTimeRate); @@ -1103,17 +1378,33 @@ public class IZtCountService { } + /** + * CHG-037 运维工程师(新 Excel):大项任务20/监控15/巡检10/备份10/其他运维15/稳定性20/不规范行为10 + */ + PerformanceDTO buildYwScore(ZtUser u, List approvalList, Date firstDayOfMonth, Date lastDayOfMonth, List taskList, Date d) { + PerformanceDTO dto = new PerformanceDTO(); + dto.setUserName(u.getNickname()); + dto.setAccount(u.getAccount()); + String month = DateUtils.formatDate(d, "yyyy-MM"); + com.sa.zentao.service.perf.PerfMonthScope scope = perfReportService.report(month, "opsEngineer", u.getAccount()); + fillRawDetail(dto, scope); + dto.setOpsMajorTaskScore(perfItemScore(scope, "opsMajorTask", BigDecimal.valueOf(20))); + dto.setOpsMonitorScore(perfItemScore(scope, "opsMonitor", BigDecimal.valueOf(15))); + dto.setOpsInspectScore(perfItemScore(scope, "opsInspect", BigDecimal.valueOf(10))); + dto.setOpsBackupScore(perfItemScore(scope, "opsBackup", BigDecimal.valueOf(10))); + //人工评审项满分默认:其他运维 15 / 系统稳定性 20 / 不规范行为 10 + dto.setOtherOpsScore(BigDecimal.valueOf(15)); + dto.setSystemStabilityScore(BigDecimal.valueOf(20)); + dto.setWorkAttitude(BigDecimal.valueOf(10)); + return dto; + } + PerformanceDTO buildXMJLScore(ZtUser u, List approvalList, Date firstDayOfMonth, Date lastDayOfMonth, List taskList, Date d) { PerformanceDTO dto = new PerformanceDTO(); dto.setUserName(u.getNickname()); dto.setAccount(u.getAccount()); - dto = getMeetScore(u, firstDayOfMonth, lastDayOfMonth, dto); - dto.setDocumentQualityScore(BigDecimal.valueOf(10)); -// //拆分任务工时 -// dto.setSplitTimeScore(s); - //本月天数 int workDaysInCurrentMonth = DateUtils.getWorkDaysInCurrentMonth(d); //获取本月请假 @@ -1127,8 +1418,27 @@ public class IZtCountService { //本月天数 请假天数 dto.setApprovalDays(applyTime); - - dto.setMeetScore(getSearchMeetScore(u, firstDayOfMonth, lastDayOfMonth)); + //CHG-037 新 Excel 项目经理口径(王宇航走 projectManagerWyh 变体:无 PRD 项、团队完成率40、稳定性20): + //PRD完成率20/团队完成率30/版本计划10/线上Bug10/文档齐备10/问题管理5/稳定性10/技能5,得分由新绩效引擎(zt_perf_config)加权产出 + boolean wyh = "wangyuhang".equals(u.getAccount()); + String month = DateUtils.formatDate(d, "yyyy-MM"); + com.sa.zentao.service.perf.PerfMonthScope scope = perfReportService.report(month, + wyh ? "projectManagerWyh" : "projectManager", u.getAccount()); + fillRawDetail(dto, scope); + if (!wyh) { + dto.setWorkloadPrdScore(perfItemScore(scope, "workloadRatePrd", BigDecimal.valueOf(20))); + dto.setWorkloadTeamScore(perfItemScore(scope, "workloadRateTeam", BigDecimal.valueOf(30))); + dto.setSystemStabilityScore(BigDecimal.valueOf(10)); + } else { + dto.setWorkloadTeamScore(perfItemScore(scope, "workloadRateTeam", BigDecimal.valueOf(40))); + dto.setSystemStabilityScore(BigDecimal.valueOf(20)); + } + dto.setVersionPlanFinishedRate(perfItemScore(scope, "versionPlanRate", BigDecimal.valueOf(10))); + dto.setBugScore(perfItemScore(scope, "onlineBugRate", BigDecimal.valueOf(10))); + dto.setDocumentQualityScore(perfItemScore(scope, "docReadyScore", BigDecimal.valueOf(10))); + //人工评审项满分默认:问题管理 5 / 专业技能提升 5 + dto.setDevelopFeedbackStory(BigDecimal.valueOf(5)); + dto.setProfessionalSkillEnhancementScore(BigDecimal.valueOf(5)); return dto; } @@ -1170,19 +1480,19 @@ public class IZtCountService { dto.setDays(BigDecimal.valueOf(workDaysInCurrentMonth)); //本月天数 请假天数 dto.setApprovalDays(applyTime); - //可用工时 6小时算 - dto.setTotalTime( - dto.getApprovalDays().intValue() == 0 ? dto.getDays().multiply(BigDecimal.valueOf(8)).setScale(2, BigDecimal.ROUND_HALF_UP) - : dto.getDays().multiply(BigDecimal.valueOf(8)).subtract(dto.getApprovalDays()).setScale(2, BigDecimal.ROUND_HALF_UP)); - //达标工时 - dto.setExamineTime(dto.getTotalTime().multiply(BigDecimal.valueOf(0.75))); - - dto = getReleaseScore(u, firstDayOfMonth, dto); - - dto = getMeetScore(u, firstDayOfMonth, lastDayOfMonth, dto); - dto.setDocumentQualityScore(BigDecimal.valueOf(50)); - dto.setProjectDocumentScore(BigDecimal.valueOf(50)); + //CHG-065 新 Excel 产品助理口径(用户拍板"按照新的excel来",撤销 08-05 对该角色的还原): + //PRD完成率50/及时验收20(人工默认满分,zt_story_user 验收链断裂维持人工)/产品缺陷率15/问题响应10/主动性5 + //得分由新绩效引擎(zt_perf_config)加权产出 + String month = DateUtils.formatDate(d, "yyyy-MM"); + com.sa.zentao.service.perf.PerfMonthScope scope = perfReportService.report(month, "productAssistant", u.getAccount()); + fillRawDetail(dto, scope); + dto.setWorkloadPrdScore(perfItemScore(scope, "workloadRatePrd", BigDecimal.valueOf(50))); + dto.setReleaseScore(BigDecimal.valueOf(20)); + dto.setProductBugRate(perfItemScore(scope, "productDefectRate", BigDecimal.valueOf(15))); + //人工评审项满分默认:问题响应和解决 10 / 主动性与责任感 5 + dto.setProductProblemResponse(BigDecimal.valueOf(10)); + dto.setProductResponsibilityScore(BigDecimal.valueOf(5)); return dto; } @@ -1190,63 +1500,47 @@ public class IZtCountService { PerformanceDTO buildCPJLScore(ZtUser u,Map userMap,ZtProjectQo qo, List approvalList, Date firstDayOfMonth, Date lastDayOfMonth, List taskList, Date d,List pids) { /** - * 产品经理 + * 产品经理(CHG-037 新 Excel 口径):PRD完成率40/团队完成率20/项目准时率10/产品缺陷率15/问题响应10/主动性5 + * 得分由新绩效引擎(zt_perf_config)加权产出;展示字段(发布数量/Bug 数)保留原取数 */ PerformanceDTO dto = new PerformanceDTO(); dto.setUserName(u.getNickname()); dto.setAccount(u.getAccount()); - //项目准时率 Σ (当月上线准时上线的需求量) / Σ (当月规划上线的需求总量) * 100≥95% -// dto.setProjectOnTimeRate(); - //产品缺陷率 -// dto.setProductBugRate(); + //展示字段:本阶段计划/准时发布数量 Map map=this.storyService.storyReleaseOnTimeByProducts(pids,firstDayOfMonth,lastDayOfMonth); Integer planReleaseStoryNum = Integer.valueOf(map.getOrDefault("planReleaseStoryNum", 0).toString()); Integer storyOnTimeNum = Integer.valueOf(map.getOrDefault("onTimeNum", 0).toString()); dto.setProductPlanStoryNum(BigDecimal.valueOf(planReleaseStoryNum)); dto.setProductPlanStoryOnTimeNum(BigDecimal.valueOf(storyOnTimeNum)); - int storyOnTimeRate = ObjectUtil.equal(planReleaseStoryNum, 0) ? 0 : - (BigDecimal.valueOf(storyOnTimeNum*100).divide(BigDecimal.valueOf(planReleaseStoryNum), 0, BigDecimal.ROUND_UP)).intValue(); - int productProjectOnTimeRateScore=storyOnTimeRate==0?0: (storyOnTimeRate>=95)? 20: - (storyOnTimeRate>=90&&storyOnTimeRate<95)?(20-(95-storyOnTimeRate)): - ((90-storyOnTimeRate)*2+5>20)? 0:(20-((90-storyOnTimeRate)*2+5)); - dto.setProductProjectOnTimeRateScore(BigDecimal.valueOf(productProjectOnTimeRateScore)); - List allBugList = this.bugService.list(new QueryWrapper().lambda().select(SFunctionColums.bugColumes()) - .eq(ZtBug::getOpenedby, u.getAccount()) - .in(ZtBug::getBugType,"releaseBug","prod") - .ge(ZtBug::getOpeneddate,firstDayOfMonth) - .le(ZtBug::getOpeneddate,lastDayOfMonth) - .in(ZtBug::getProduct,pids)); - allBugList = this.bugService.getNormalBugList(allBugList); - //开发分配工时 - ZtCountQo ztCountQo = new ZtCountQo(); - BeanUtils.copyProperties(qo, ztCountQo); - List ztProducts = this.productService.listByIds(pids); - List perList = this.programCount(ztCountQo,ztProducts.stream().map(o->o.getProgram()).collect(Collectors.toList())); - dto = buildTaskManage(dto, perList, userMap,firstDayOfMonth,lastDayOfMonth,pids); - //开发任务分配工时 - BigDecimal allocationTime = dto.getAllocationTime(); - double bugRate=(allocationTime.doubleValue()==0||allBugList.size()==0)?0:BigDecimal.valueOf(allBugList.size()*1000).divide(allocationTime,2,BigDecimal.ROUND_HALF_UP).doubleValue(); - dto.setBugDensity(BigDecimal.valueOf(bugRate)); - if(bugRate<=5){ - dto.setBugScore(BigDecimal.valueOf(20)); - }else{ -// long deductBugScore = Math.min(seriousBug * 10L + slightBug * 3L, 20L); -// dto.setBugScore(BigDecimal.valueOf(20L-deductBugScore)); - //严重10 分 轻微3分一个 - int bugScore = 0; - for (ZtBug b : allBugList) { - bugScore += b.getSeverity() != 1 ? 3 : 10; - } - dto.setBugScore(bugScore>20?BigDecimal.ZERO:BigDecimal.valueOf((20 - bugScore))); - } - dto.setProductBugRate(dto.getBugScore()); - //线上重大 - dto.setSeriousBug(BigDecimal.valueOf(allBugList.stream().filter(o -> Arrays.asList(1).contains(o.getSeverity())).count())); - //线上轻微 - dto.setSlightBug( BigDecimal.valueOf(allBugList.stream().filter(o -> Arrays.asList(4, 2, 3).contains(o.getSeverity())).count())); + //展示字段:线上 Bug 数,与引擎 productDefectRate 同口径——当月上线需求(releaseddate 当月、产品∈pids)关联的 prod Bug + //severity 1=重大、2/3/4=普通(CHG-056 以老弹窗为准) + List monthReleaseStories = this.storyService.list(new QueryWrapper().lambda() + .select(ZtStory::getId) + .in(ZtStory::getProduct, pids) + .ge(ZtStory::getReleaseddate, firstDayOfMonth) + .le(ZtStory::getReleaseddate, lastDayOfMonth)); + List monthStoryIds = monthReleaseStories.stream().map(ZtStory::getId).collect(Collectors.toList()); + List allBugList = CollectionUtils.isEmpty(monthStoryIds) ? java.util.Collections.emptyList() + : this.bugService.list(new QueryWrapper().lambda().select(SFunctionColums.bugColumes()) + .eq(ZtBug::getBugType, "prod") + .in(ZtBug::getTostory, monthStoryIds)); //CHG-067:需求关联字段 story(死字段)→ toStory + dto.setSeriousBug(BigDecimal.valueOf(allBugList.stream().filter(o -> o.getSeverity() != null && o.getSeverity() == 1).count())); + dto.setSlightBug(BigDecimal.valueOf(allBugList.stream().filter(o -> o.getSeverity() != null && o.getSeverity() >= 2).count())); + + //得分(scope 驱动) + String month = DateUtils.formatDate(d, "yyyy-MM"); + com.sa.zentao.service.perf.PerfMonthScope scope = perfReportService.report(month, "productManager", u.getAccount()); + fillRawDetail(dto, scope); + dto.setWorkloadPrdScore(perfItemScore(scope, "workloadRatePrd", BigDecimal.valueOf(40))); + dto.setWorkloadTeamScore(perfItemScore(scope, "workloadRateTeam", BigDecimal.valueOf(20))); + dto.setProductProjectOnTimeRateScore(perfItemScore(scope, "projectPunctualityRate", BigDecimal.valueOf(10))); + dto.setProductBugRate(perfItemScore(scope, "productDefectRate", BigDecimal.valueOf(15))); + //人工评审项满分默认:问题响应 10 / 主动性 5 + dto.setProductProblemResponse(BigDecimal.valueOf(10)); + dto.setProductResponsibilityScore(BigDecimal.valueOf(5)); return dto; } @@ -1425,17 +1719,8 @@ public class IZtCountService { //饱和率得分 dto.setPunctualityScore(BigDecimal.valueOf(25)); } -// if(CollectionUtils.isEmpty(taskTimeList)){ -// bugList=new ArrayList<>(); -// }else{ -// bugList = this.bugService.list(new QueryWrapper().lambda().between(ZtBug::getOpeneddate, firstDayOfMonth, lastDayOfMonth) -// .in(ZtBug::getSeverity, 1, 2).eq(ZtBug::getResolvedby,u.getAccount())); -// } List bugList = this.bugService.list(new QueryWrapper().lambda().between(ZtBug::getOpeneddate, firstDayOfMonth, lastDayOfMonth) .in(ZtBug::getSeverity, 1, 2).eq(ZtBug::getResolvedby, u.getAccount())); -// if(!CollectionUtils.isEmpty(fTask)){ -// count = bugService.count(new QueryWrapper().lambda().in(ZtBug::getTotask, fTask.stream().map(o -> o.getId()).collect(Collectors.toList()))); -// } if (CollectionUtils.isEmpty(bugList)) { dto.setSeriousBug(BigDecimal.ZERO); @@ -2176,44 +2461,40 @@ public class IZtCountService { Map dataMap = new HashMap<>(); dataMap.put("name", name); dataMap.put("date", DateUtils.formatDate(d, "yyyy-MM")); - dataMap.put("准时率得分", performanceDTO.getPunctualityScore() ==null?"0":performanceDTO.getPunctualityScore().toString()); - dataMap.put("Bug密度", performanceDTO.getBugScore()==null?"0": performanceDTO.getBugScore().toString()); - dataMap.put("工作量饱和度", performanceDTO.getSaturationScore()==null?"0": performanceDTO.getSaturationScore().toString()); + //CHG-038:按开发方向分流前端/后端模版,前端模版无 documentQualityScore 键 + boolean frontend = "frontend".equals(performanceDTO.getDevDirection()); - dataMap.put("代码质量", performanceDTO.getCodeQualityScore()==null?"0":performanceDTO.getCodeQualityScore().toString()); + dataMap.put("punctualityScore", scoreStr(performanceDTO.getPunctualityScore())); + dataMap.put("bugScore", scoreStr(performanceDTO.getBugScore())); + dataMap.put("codeQualityScore", scoreStr(performanceDTO.getCodeQualityScore())); + if (!frontend) { + dataMap.put("documentQualityScore", scoreStr(performanceDTO.getDocumentQualityScore())); + } + dataMap.put("saturationScore", scoreStr(performanceDTO.getSaturationScore())); + dataMap.put("workAttitude", scoreStr(performanceDTO.getWorkAttitude())); + dataMap.put("excellentShare", scoreStr(performanceDTO.getExcellentShare())); + dataMap.put("quality", scoreStr(performanceDTO.getQuality())); - dataMap.put("工作态度", performanceDTO.getWorkAttitude()==null?"0":performanceDTO.getWorkAttitude().toString()); - //工作态度 不规范行为 - dataMap.put("文档质量", performanceDTO.getDocumentQualityScore()==null?"0":performanceDTO.getDocumentQualityScore().toString()); - //创新贡献 优质分享 - dataMap.put("创新贡献", performanceDTO.getExcellentShare()==null?"0":performanceDTO.getExcellentShare().toString()); - // - dataMap.put("质量贡献", performanceDTO.getQuality()==null?"0":performanceDTO.getQuality().toString()); + dataMap.put("totalTask", scoreStr(performanceDTO.getTotalTask())); + dataMap.put("delayTask", scoreStr(performanceDTO.getDelayTask())); + dataMap.put("finishPunctuality", multiply100(performanceDTO.getFinishPunctuality())); + dataMap.put("workTime", scoreStr(performanceDTO.getWorkTime())); + dataMap.put("saturation", multiply100(performanceDTO.getSaturation())); + dataMap.put("examineTime", scoreStr(performanceDTO.getExamineTime())); + dataMap.put("bugCount", scoreStr(performanceDTO.getBugCount())); + dataMap.put("bugDensity", multiply100(performanceDTO.getBugDensity())); - dataMap.put("totalTask", performanceDTO.getTotalTask().toString()); - dataMap.put("delayTask", performanceDTO.getDelayTask().toString()); - dataMap.put("finishPunctuality", performanceDTO.getFinishPunctuality().multiply(BigDecimal.valueOf(100)).toString()); - - dataMap.put("workTime", performanceDTO.getWorkTime().toString()); - dataMap.put("saturation", performanceDTO.getSaturation().multiply(BigDecimal.valueOf(100)).toString()); - dataMap.put("examineTime", performanceDTO.getExamineTime().toString()); - - dataMap.put("bugCount", performanceDTO.getBugCount().toString()); - dataMap.put("bugDensity", performanceDTO.getBugDensity().multiply(BigDecimal.valueOf(100)).toString()); - - - dataMap.put("总分", devlopTotal( - dataMap.get("工作量饱和度"), - dataMap.get("准时率得分"), - dataMap.get("Bug密度"), - dataMap.get("代码质量"), - dataMap.get("文档质量"), - dataMap.get("工作态度"), - - dataMap.get("创新贡献"), - dataMap.get("质量贡献") - )); - writeXlsx(name, "templates/scope/开发工程师.xlsx", name + "开发.xlsx", dataMap); + if (frontend) { + dataMap.put("total", scoreTotal(performanceDTO.getPunctualityScore(), performanceDTO.getBugScore(), + performanceDTO.getCodeQualityScore(), performanceDTO.getSaturationScore(), + performanceDTO.getWorkAttitude(), performanceDTO.getExcellentShare(), performanceDTO.getQuality())); + writeXlsx(name, "templates/scope/前端开发工程师.xlsx", name + "开发.xlsx", dataMap); + } else { + dataMap.put("total", scoreTotal(performanceDTO.getPunctualityScore(), performanceDTO.getBugScore(), + performanceDTO.getCodeQualityScore(), performanceDTO.getDocumentQualityScore(), performanceDTO.getSaturationScore(), + performanceDTO.getWorkAttitude(), performanceDTO.getExcellentShare(), performanceDTO.getQuality())); + writeXlsx(name, "templates/scope/后端开发工程师.xlsx", name + "开发.xlsx", dataMap); + } } @@ -2222,107 +2503,81 @@ public class IZtCountService { dataMap.put("name", name); dataMap.put("date", DateUtils.formatDate(d, "yyyy-MM")); - dataMap.put("及时完成率得分", performanceDTO.getPunctualityScore()==null?"0":performanceDTO.getPunctualityScore().toString()); - dataMap.put("设计质量", performanceDTO.getDesignScore()==null?"0":performanceDTO.getDesignScore().toString()); - dataMap.put("工作态度", performanceDTO.getWorkAttitude()==null?"0":performanceDTO.getWorkAttitude().toString()); - dataMap.put("工作量", performanceDTO.getUiWorkScore()==null?"0":performanceDTO.getUiWorkScore().toString()); - dataMap.put("创新贡献", performanceDTO.getExcellentShare()==null?"0":performanceDTO.getExcellentShare().toString()); + dataMap.put("punctualityScore", scoreStr(performanceDTO.getPunctualityScore())); + dataMap.put("designScore", scoreStr(performanceDTO.getDesignScore())); + dataMap.put("workAttitude", scoreStr(performanceDTO.getWorkAttitude())); + dataMap.put("uiWorkScore", scoreStr(performanceDTO.getUiWorkScore())); + dataMap.put("excellentShare", scoreStr(performanceDTO.getExcellentShare())); + dataMap.put("totalTask", scoreStr(performanceDTO.getTotalTask())); + dataMap.put("delayTask", scoreStr(performanceDTO.getDelayTask())); + dataMap.put("finishPunctuality", multiply100(performanceDTO.getFinishPunctuality())); - dataMap.put("任务总量", performanceDTO.getTotalTask()==null?"0":performanceDTO.getTotalTask().toString()); - dataMap.put("超期数量", performanceDTO.getDelayTask()==null?"0":performanceDTO.getDelayTask().toString()); - dataMap.put("及时完成率", performanceDTO.getFinishPunctuality()==null?"0": performanceDTO.getFinishPunctuality().toString()); - - dataMap.put("总分", devlopTotal(dataMap.get("及时完成率得分"), dataMap.get("设计质量"), dataMap.get("工作态度"), dataMap.get("工作量"), dataMap.get("创新贡献"))); + dataMap.put("total", scoreTotal(performanceDTO.getPunctualityScore(), performanceDTO.getDesignScore(), + performanceDTO.getWorkAttitude(), performanceDTO.getUiWorkScore(), performanceDTO.getExcellentShare())); writeXlsx(name, "templates/scope/UI工程师.xlsx", name + "UI工程师.xlsx", dataMap); } void generatorXMJLExcel(ZtCountQo qo, String name, PerformanceDTO performanceDTO, Date d, List perList) { - Map userMap = userService.userMapByIds(null); Map dataMap = new HashMap<>(); dataMap.put("name", name); dataMap.put("date", DateUtils.formatDate(d, "yyyy-MM")); + //CHG-037 新版式:王宇航单独模版,不考核需求PRD工作量指标 + boolean wangyuhang = "wangyuhang".equals(performanceDTO.getAccount()); - dataMap.put("任务管理", taskManageScore(perList, userMap)); - dataMap.put("bug", bugManageScore(qo, perList, userMap)); + if (!wangyuhang) { + dataMap.put("workloadPrdScore", scoreStr(performanceDTO.getWorkloadPrdScore())); + dataMap.put("detail_workloadPrdScore", perfDetail(performanceDTO, "workloadRatePrd")); + } + dataMap.put("workloadTeamScore", scoreStr(performanceDTO.getWorkloadTeamScore())); + dataMap.put("versionPlanFinishedRate", scoreStr(performanceDTO.getVersionPlanFinishedRate())); + dataMap.put("bugScore", scoreStr(performanceDTO.getBugScore())); + dataMap.put("documentQualityScore", scoreStr(performanceDTO.getDocumentQualityScore())); + dataMap.put("developFeedbackStory", scoreStr(performanceDTO.getDevelopFeedbackStory())); + dataMap.put("systemStabilityScore", scoreStr(performanceDTO.getSystemStabilityScore())); + dataMap.put("professionalSkillEnhancementScore", scoreStr(performanceDTO.getProfessionalSkillEnhancementScore())); - dataMap.put("版本计划完成率", versionPlanScore(qo, perList, userMap)); - dataMap.put("项目文档", performanceDTO.getDocumentQualityScore()==null?"0":performanceDTO.getDocumentQualityScore().toString()); - dataMap.put("会议管理", performanceDTO.getMeetScore()==null?"0": performanceDTO.getMeetScore() + ""); - dataMap.put("问题管理", performanceDTO.getDevelopFeedbackStory()==null?"0":performanceDTO.getDevelopFeedbackStory().toString()); - dataMap.put("系统稳定性", performanceDTO.getSystemStabilityScore()==null?"0": performanceDTO.getSystemStabilityScore().toString()); - dataMap.put("专业技能提升", performanceDTO.getProfessionalSkillEnhancementScore()==null?"0":performanceDTO.getProfessionalSkillEnhancementScore().toString()); + dataMap.put("detail_workloadTeamScore", perfDetail(performanceDTO, "workloadRateTeam")); + dataMap.put("detail_versionPlanFinishedRate", perfDetail(performanceDTO, "versionPlanRate")); + dataMap.put("detail_bugScore", perfDetail(performanceDTO, "onlineBugRate")); + dataMap.put("detail_documentQualityScore", perfDetail(performanceDTO, "docReadyScore")); - - dataMap.put("普通bug",performanceDTO.getSlightBug()==null?"0":performanceDTO.getSlightBug().toString()); - dataMap.put("重大bug", performanceDTO.getSeriousBug()==null?"0": performanceDTO.getSeriousBug().toString()); - dataMap.put("线上bug率",performanceDTO.getBugDensity()==null?"0": performanceDTO.getBugDensity().multiply(BigDecimal.valueOf(100)).toString()); - dataMap.put("分配工时", performanceDTO.getAllocationTime()==null?"0": performanceDTO.getAllocationTime().toString()); - dataMap.put("可用工时", performanceDTO.getExamineTime()==null?"0": performanceDTO.getExamineTime().toString()); - dataMap.put("分配工时占比", performanceDTO.getAllocationTimeManageRate()==null?"0": performanceDTO.getAllocationTimeManageRate().multiply(BigDecimal.valueOf(100)).toString()); - dataMap.put("召开周会数量", performanceDTO.getMeetWeek()==null?"0": performanceDTO.getMeetWeek().toString()); - dataMap.put("召开需求会议数量", performanceDTO.getMeetStory()==null?"0": performanceDTO.getMeetStory().toString()); - - - - dataMap.put("总分", devlopTotal( - dataMap.get("版本计划完成率"), - dataMap.get("bug"), - dataMap.get("项目文档"), - dataMap.get("任务管理"), - dataMap.get("会议管理"), - dataMap.get("问题管理"), - dataMap.get("系统稳定性"), - dataMap.get("专业技能提升") - )); - - writeXlsx(name, "templates/scope/项目经理考核.xlsx", name + "项目经理考核.xlsx", dataMap); + if (wangyuhang) { + dataMap.put("total", scoreTotal(performanceDTO.getWorkloadTeamScore(), performanceDTO.getVersionPlanFinishedRate(), + performanceDTO.getBugScore(), performanceDTO.getDocumentQualityScore(), performanceDTO.getDevelopFeedbackStory(), + performanceDTO.getSystemStabilityScore(), performanceDTO.getProfessionalSkillEnhancementScore())); + writeXlsx(name, "templates/scope/项目经理考核-王宇航.xlsx", name + "项目经理考核.xlsx", dataMap); + } else { + dataMap.put("total", scoreTotal(performanceDTO.getWorkloadPrdScore(), performanceDTO.getWorkloadTeamScore(), + performanceDTO.getVersionPlanFinishedRate(), performanceDTO.getBugScore(), performanceDTO.getDocumentQualityScore(), + performanceDTO.getDevelopFeedbackStory(), performanceDTO.getSystemStabilityScore(), performanceDTO.getProfessionalSkillEnhancementScore())); + writeXlsx(name, "templates/scope/项目经理考核.xlsx", name + "项目经理考核.xlsx", dataMap); + } } void generatorCPJLExcel(ZtCountQo qo, String name, PerformanceDTO performanceDTO, Date d, List perList) { - Map userMap = userService.userMapByIds(null); Map dataMap = new HashMap<>(); dataMap.put("name", name); dataMap.put("date", DateUtils.formatDate(d, "yyyy-MM")); - //产品规划 - dataMap.put("productPlanning",performanceDTO.getProductPlanning()==null?"0":performanceDTO.getProductPlanning().toString()); - //需求准确性 - dataMap.put("accurateDemand", performanceDTO.getAccurateDemand()==null?"0": performanceDTO.getAccurateDemand().toString()); - //项目准时率 - dataMap.put("productProjectOnTimeRateScore",performanceDTO.getProductProjectOnTimeRateScore()==null?"0": performanceDTO.getProductProjectOnTimeRateScore().toString()); - //线上Bug得分 缺陷 - dataMap.put("bugScope", performanceDTO.getBugScore()==null?"0": performanceDTO.getBugScore().toString()); - //开发分配工时 - dataMap.put("allocationTime", performanceDTO.getBugScore()==null?"0": performanceDTO.getAllocationTime().toString()); - //轻微 - dataMap.put("slightBug", performanceDTO.getSlightBug()==null?"0": performanceDTO.getSlightBug().toString()); - dataMap.put("seriousBug", performanceDTO.getSeriousBug()==null?"0": performanceDTO.getSeriousBug().toString()); - //问题响应 - dataMap.put("productProblemResponse", performanceDTO.getProductProblemResponse()==null?"0": performanceDTO.getProductProblemResponse().toString()); - //会议管理 - dataMap.put("productMeetScore", performanceDTO.getProductMeetScore()==null?"0": performanceDTO.getProductMeetScore().toString()); - //主动性责任感 - dataMap.put("productResponsibilityScore", performanceDTO.getProductResponsibilityScore()==null?"0": performanceDTO.getProductResponsibilityScore().toString()); - dataMap.put("productPlanStoryNum", performanceDTO.getProductPlanStoryNum()==null?"0": performanceDTO.getProductPlanStoryNum().toString()); - dataMap.put("productPlanStoryOnTimeNum", performanceDTO.getProductPlanStoryOnTimeNum()==null?"0": performanceDTO.getProductPlanStoryOnTimeNum().toString()); - dataMap.put("allocationTime", performanceDTO.getAllocationTime()==null?"0": performanceDTO.getAllocationTime().toString()); - dataMap.put("slightBug", performanceDTO.getSlightBug()==null?"0": performanceDTO.getSlightBug().toString()); - dataMap.put("seriousBug", performanceDTO.getSeriousBug()==null?"0": performanceDTO.getSeriousBug().toString()); - dataMap.put("bugDensity", performanceDTO.getBugDensity()==null?"0": performanceDTO.getBugDensity().toString()); + dataMap.put("workloadPrdScore", scoreStr(performanceDTO.getWorkloadPrdScore())); + dataMap.put("workloadTeamScore", scoreStr(performanceDTO.getWorkloadTeamScore())); + dataMap.put("productProjectOnTimeRateScore", scoreStr(performanceDTO.getProductProjectOnTimeRateScore())); + dataMap.put("productBugRate", scoreStr(performanceDTO.getProductBugRate())); + dataMap.put("productProblemResponse", scoreStr(performanceDTO.getProductProblemResponse())); + dataMap.put("productResponsibilityScore", scoreStr(performanceDTO.getProductResponsibilityScore())); - dataMap.put("总分", devlopTotal( - dataMap.get("productPlanning"), - dataMap.get("accurateDemand"), - dataMap.get("productProjectOnTimeRateScore"), - dataMap.get("bugScope"), - dataMap.get("productProblemResponse"), - dataMap.get("productMeetScore"), - dataMap.get("productResponsibilityScore") - )); + dataMap.put("detail_workloadPrdScore", perfDetail(performanceDTO, "workloadRatePrd")); + dataMap.put("detail_workloadTeamScore", perfDetail(performanceDTO, "workloadRateTeam")); + dataMap.put("detail_productProjectOnTimeRateScore", perfDetail(performanceDTO, "projectPunctualityRate")); + dataMap.put("detail_productBugRate", perfDetail(performanceDTO, "productDefectRate")); + + dataMap.put("total", scoreTotal(performanceDTO.getWorkloadPrdScore(), performanceDTO.getWorkloadTeamScore(), + performanceDTO.getProductProjectOnTimeRateScore(), performanceDTO.getProductBugRate(), + performanceDTO.getProductProblemResponse(), performanceDTO.getProductResponsibilityScore())); writeXlsx(name, "templates/scope/产品经理考核.xlsx", name + "产品经理考核.xlsx", dataMap); @@ -2363,6 +2618,8 @@ public class IZtCountService { for (Map.Entry entry : dataMap.entrySet()) { cellValue = cellValue.replace("{" + entry.getKey() + "}", entry.getValue()); } + //openpyxl 生成的模版是 inlineStr 单元格,直接 setCellValue 会残留旧 内联串导致读回旧值 → 先置空再写 + cell.setCellType(CellType.BLANK); cell.setCellValue(cellValue); } } @@ -2576,20 +2833,18 @@ public class IZtCountService { Map dataMap = new HashMap<>(); dataMap.put("name", name); dataMap.put("date", DateUtils.formatDate(d, "yyyy-MM")); - dataMap.put("项目绩效", performanceDTO.getReleaseScore()==null?"0":performanceDTO.getReleaseScore().toString()); - dataMap.put("项目文档", performanceDTO.getProjectDocumentScore()==null?"0": performanceDTO.getProjectDocumentScore().toString()); - dataMap.put("会议管理", performanceDTO.getMeetScore()==null?"0": performanceDTO.getMeetScore().toString()); + dataMap.put("workloadPrdScore", scoreStr(performanceDTO.getWorkloadPrdScore())); + dataMap.put("releaseScore", scoreStr(performanceDTO.getReleaseScore())); + dataMap.put("productBugRate", scoreStr(performanceDTO.getProductBugRate())); + dataMap.put("productProblemResponse", scoreStr(performanceDTO.getProductProblemResponse())); + dataMap.put("productResponsibilityScore", scoreStr(performanceDTO.getProductResponsibilityScore())); - dataMap.put("上线需求数量", performanceDTO.getReleaseCount()==null?"0": performanceDTO.getReleaseCount().toString()); - dataMap.put("及时验收数量", performanceDTO.getReleaseOnTimeCount()==null?"0":performanceDTO.getReleaseOnTimeCount().toString()); - dataMap.put("及时验收率", performanceDTO.getReleaseOnTimeRate()==null?"0": performanceDTO.getReleaseOnTimeRate().multiply(BigDecimal.valueOf(100)).toString()); - dataMap.put("召开周会数量", performanceDTO.getMeetWeek()==null?"0":performanceDTO.getMeetWeek().toString()); - dataMap.put("召开需求数量", performanceDTO.getMeetStory()==null?"0": performanceDTO.getMeetStory().toString()); + dataMap.put("detail_workloadPrdScore", perfDetail(performanceDTO, "workloadRatePrd")); + dataMap.put("detail_productBugRate", perfDetail(performanceDTO, "productDefectRate")); - - - dataMap.put("总分", devlopTotal(dataMap.get("项目绩效"), dataMap.get("项目文档"), dataMap.get("会议管理"))); + dataMap.put("total", scoreTotal(performanceDTO.getWorkloadPrdScore(), performanceDTO.getReleaseScore(), + performanceDTO.getProductBugRate(), performanceDTO.getProductProblemResponse(), performanceDTO.getProductResponsibilityScore())); writeXlsx(name, "templates/scope/项目助理考核.xlsx", name + "项目助理考核.xlsx", dataMap); @@ -2608,48 +2863,139 @@ public class IZtCountService { return i + ""; } + //CHG-037 新版式导出:得分取值,null -> "0" + private String scoreStr(BigDecimal value) { + return value == null ? "0" : value.toString(); + } + + //比率类过程值 ×100 后导出,null -> "0" + private String multiply100(BigDecimal value) { + return value == null ? "0" : value.multiply(BigDecimal.valueOf(100)).toString(); + } + + //合计:BigDecimal 相加保留 1 位小数,null 按 0 计 + private String scoreTotal(BigDecimal... scores) { + BigDecimal total = BigDecimal.ZERO; + for (BigDecimal score : scores) { + if (score != null) { + total = total.add(score); + } + } + return total.setScale(1, BigDecimal.ROUND_HALF_UP).toPlainString(); + } + + //CHG-041 绩效数据列明细:perfRawDetail(itemKey -> 明细串),缺失返回空串清掉占位符 + private String perfDetail(PerformanceDTO performanceDTO, String itemKey) { + Map rawDetail = performanceDTO.getPerfRawDetail(); + if (rawDetail == null || rawDetail.get(itemKey) == null) { + return ""; + } + return rawDetail.get(itemKey); + } + void generatorTestExcel(String name, PerformanceDTO performanceDTO, Date d) { Map dataMap = new HashMap<>(); dataMap.put("name", name); dataMap.put("date", DateUtils.formatDate(d, "yyyy-MM")); - dataMap.put("准时率得分", performanceDTO.getPunctualityScore()==null?"0": performanceDTO.getPunctualityScore().toString()); - dataMap.put("缺陷检出率", performanceDTO.getBugFindRate() ==null?"0":performanceDTO.getBugFindRate().multiply(BigDecimal.valueOf(100)).toString()); - dataMap.put("测试文档", performanceDTO.getDocumentQualityScore()==null?"0":performanceDTO.getDocumentQualityScore().toString()); - dataMap.put("工作态度", performanceDTO.getWorkAttitude()==null?"0":performanceDTO.getWorkAttitude().toString()); - dataMap.put("质量贡献", performanceDTO.getExcellentShare()==null?"0":performanceDTO.getExcellentShare().toString()); + dataMap.put("punctualityScore", scoreStr(performanceDTO.getPunctualityScore())); + dataMap.put("documentQualityScore", scoreStr(performanceDTO.getDocumentQualityScore())); + dataMap.put("bugFindScore", scoreStr(performanceDTO.getBugFindScore())); + dataMap.put("bugScore", scoreStr(performanceDTO.getBugScore())); + dataMap.put("workAttitude", scoreStr(performanceDTO.getWorkAttitude())); + dataMap.put("excellentShare", scoreStr(performanceDTO.getExcellentShare())); + dataMap.put("quality", scoreStr(performanceDTO.getQuality())); - dataMap.put("线上Bug", performanceDTO.getBugScore()==null?"0":performanceDTO.getBugScore().toString()); + dataMap.put("totalTask", scoreStr(performanceDTO.getTotalTask())); + dataMap.put("delayTask", scoreStr(performanceDTO.getDelayTask())); + dataMap.put("finishPunctuality", multiply100(performanceDTO.getFinishPunctuality())); + dataMap.put("devSlightBug", scoreStr(performanceDTO.getDevSlightBug())); + dataMap.put("devSeriousBug", scoreStr(performanceDTO.getDevSeriousBug())); + dataMap.put("bugFindRate", multiply100(performanceDTO.getBugFindRate())); + dataMap.put("slightBug", scoreStr(performanceDTO.getSlightBug())); + dataMap.put("seriousBug", scoreStr(performanceDTO.getSeriousBug())); - - - - dataMap.put("任务总量", performanceDTO.getTotalTask()==null?"0": performanceDTO.getTotalTask().toString()); - dataMap.put("延期数量", performanceDTO.getDelayTask() ==null?"0":performanceDTO.getDelayTask().toString()); - dataMap.put("及时完成率", performanceDTO.getFinishPunctuality() ==null?"0":performanceDTO.getFinishPunctuality().multiply(BigDecimal.valueOf(100)).toString()); - dataMap.put("创新贡献", performanceDTO.getExcellentShare() ==null?"0":performanceDTO.getExcellentShare().toString()); - - dataMap.put("测试普通bug", performanceDTO.getDevSlightBug()==null?"0": performanceDTO.getDevSlightBug().toString()); - dataMap.put("测试重大bug", performanceDTO.getDevSeriousBug() ==null?"0": performanceDTO.getDevSeriousBug().toString()); - dataMap.put("缺陷检出分数", performanceDTO.getBugFindScore()==null?"0": performanceDTO.getBugFindScore().toString()); - dataMap.put("线上普通bug", performanceDTO.getSlightBug() ==null?"0": performanceDTO.getSlightBug().toString()); - dataMap.put("线上重大bug", performanceDTO.getSeriousBug()==null?"0": performanceDTO.getSeriousBug().toString()); - - dataMap.put("总分", devlopTotal(dataMap.get("准时率得分") - , dataMap.get("缺陷检出分数"), - dataMap.get("测试文档"), dataMap.get("工作态度"), - dataMap.get("质量贡献"), dataMap.get("线上Bug"), dataMap.get("创新贡献")) - ); + dataMap.put("total", scoreTotal(performanceDTO.getPunctualityScore(), performanceDTO.getDocumentQualityScore(), + performanceDTO.getBugFindScore(), performanceDTO.getBugScore(), performanceDTO.getWorkAttitude(), + performanceDTO.getExcellentShare(), performanceDTO.getQuality())); writeXlsx(name, "templates/scope/测试工程师.xlsx", name + "测试.xlsx", dataMap); + } + + //CHG-037 新版式:运维工程师考核导出 + void generatorYwExcel(String name, PerformanceDTO performanceDTO, Date d) { + Map dataMap = new HashMap<>(); + dataMap.put("name", name); + dataMap.put("date", DateUtils.formatDate(d, "yyyy-MM")); + + dataMap.put("opsMajorTaskScore", scoreStr(performanceDTO.getOpsMajorTaskScore())); + dataMap.put("opsMonitorScore", scoreStr(performanceDTO.getOpsMonitorScore())); + dataMap.put("opsInspectScore", scoreStr(performanceDTO.getOpsInspectScore())); + dataMap.put("opsBackupScore", scoreStr(performanceDTO.getOpsBackupScore())); + dataMap.put("otherOpsScore", scoreStr(performanceDTO.getOtherOpsScore())); + dataMap.put("systemStabilityScore", scoreStr(performanceDTO.getSystemStabilityScore())); + dataMap.put("workAttitude", scoreStr(performanceDTO.getWorkAttitude())); + dataMap.put("excellentShare", scoreStr(performanceDTO.getExcellentShare())); + + dataMap.put("detail_opsMajorTaskScore", perfDetail(performanceDTO, "opsMajorTask")); + dataMap.put("detail_opsMonitorScore", perfDetail(performanceDTO, "opsMonitor")); + dataMap.put("detail_opsInspectScore", perfDetail(performanceDTO, "opsInspect")); + dataMap.put("detail_opsBackupScore", perfDetail(performanceDTO, "opsBackup")); + + dataMap.put("total", scoreTotal(performanceDTO.getOpsMajorTaskScore(), performanceDTO.getOpsMonitorScore(), + performanceDTO.getOpsInspectScore(), performanceDTO.getOpsBackupScore(), performanceDTO.getOtherOpsScore(), + performanceDTO.getSystemStabilityScore(), performanceDTO.getWorkAttitude(), performanceDTO.getExcellentShare())); + + writeXlsx(name, "templates/scope/运维工程师.xlsx", name + "运维.xlsx", dataMap); + + } @Autowired private IZtMonthScoreService monthScoreService; + @Autowired + private IZtStoryMonthWorkloadService storyMonthWorkloadService; + public PerformanceDTO myWorkScore(ZtProjectQo qo) { + //CHG-072 快照分流:老 DTO 平铺格式维持快路径(人工分+全字段都在); + //引擎 PerfMonthScope/docCheck 等格式按老 DTO 解析会全空——改走新算+人工项以快照覆盖(与 exportScope 同族修复) + Date d0 = qo.getDate() == null ? new Date() : qo.getDate(); + String name0 = StringUtils.isEmpty(qo.getAccount()) ? RiskUserThreadLocal.get().getName() : qo.getAccount(); + ZtMonthScore monthScore = null; + if (!StringUtils.isEmpty(name0)) { + String month0 = DateUtils.formatDate(d0, "yyyy-MM"); + monthScore = this.monthScoreService.getOne(new QueryWrapper().lambda() + .eq(ZtMonthScore::getAccount, name0).eq(ZtMonthScore::getDateStr, month0)); + } + if (monthScore != null && isOldDtoScoreJson(monthScore.getScopeJson())) { + PerformanceDTO performanceDTO = JSON.parseObject(monthScore.getScopeJson(), PerformanceDTO.class); + performanceDTO.setId(monthScore.getId()); + return performanceDTO; + } + PerformanceDTO fresh = myWorkScoreCompute(qo); + if (monthScore != null && !StringUtils.isBlank(monthScore.getScopeJson())) { + try { + com.alibaba.fastjson.JSONObject obj = JSON.parseObject(monthScore.getScopeJson()); + if (obj.containsKey("items") && obj.containsKey("role")) { + overlayManualScores(fresh, obj.toJavaObject(com.sa.zentao.service.perf.PerfMonthScope.class)); + } + } catch (Exception ignore) { + //docCheck 专项等非引擎格式:快照无绩效分可用,直接用新算结果 + } + fresh.setId(monthScore.getId()); + } + return fresh; + } + + /** 老 DTO 平铺快照判定:含 account 且不含引擎 items 结构(引擎 PerfMonthScope 也含 account,须排除) */ + private boolean isOldDtoScoreJson(String scopeJson) { + return !StringUtils.isBlank(scopeJson) && scopeJson.contains("\"account\"") && !scopeJson.contains("\"items\""); + } + + private PerformanceDTO myWorkScoreCompute(ZtProjectQo qo) { PerformanceDTO result = new PerformanceDTO(); Date d = qo.getDate(); if (d == null) { @@ -2663,19 +3009,15 @@ public class IZtCountService { result.setUserId(u.getId()); result.setAccount(u.getAccount()); String month = DateUtils.formatDate(d, "yyyy-MM"); - ZtMonthScore monthScore = this.monthScoreService.getOne(new QueryWrapper().lambda().eq(ZtMonthScore::getAccount, name) - .eq(ZtMonthScore::getDateStr, month)); - if (monthScore != null) { - String scopeJson = monthScore.getScopeJson(); - PerformanceDTO performanceDTO = JSON.parseObject(scopeJson, PerformanceDTO.class); - performanceDTO.setId(monthScore.getId()); - return performanceDTO; - } Date firstDayOfMonth = DateUtils.getFirstDayOfMonth(d); Date lastDayOfMonth = DateUtils.getLastDayOfMonth(d); - List pids = this.projectService.authProductListByAccount(name); + //CHG-053 按当前选中产品集(月报下拉 project=program id)算:任务/需求范围跟随选择 + List pids = qo.getProject() != null + ? this.productService.list(new QueryWrapper().lambda().eq(ZtProduct::getProgram, qo.getProject())) + .stream().map(ZtProduct::getId).collect(Collectors.toList()) + : this.projectService.authProductListByAccount(name); if (CollectionUtils.isEmpty(pids)) { @@ -2713,6 +3055,9 @@ public class IZtCountService { //获取测试需求 List cswcStoryList = this.storyService.list(new QueryWrapper().lambda().in(ZtStory::getProduct, pids) .in(ZtStory::getAssignedTo, Arrays.asList(u.getAccount())).ge(ZtStory::getReleaseddate, firstDayOfMonth).le(ZtStory::getReleaseddate, lastDayOfMonth)); + //CHG-053 引擎项目口径跟随选中产品集(月报下拉 project=program id) + com.sa.zentao.service.perf.AbstractPerfIndicatorCalculator.setCurrentProgram(qo.getProject()); + try { if (name.equals("weidongxia")) { result = buildCPJLScore(u, userMap , qo,approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d,pids); } else @@ -2721,31 +3066,25 @@ public class IZtCountService { } else if ("liushengqing".equals(name)) { result = buildUiScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d); } else if (u.getUserType() == UserType.KFZ) { - result = buildKFZScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d); + result = buildKFZScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d, accountIds); } else if (u.getUserType() == UserType.CS) { - result = buildCScore(userMap,pids,u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d,releaseBugs.stream().filter(o->u.getAccount().equals(o.getBindUser())).collect(Collectors.toList()),cswcStoryList.stream().filter(o->u.getAccount().equals(o.getAssignedTo())).collect(Collectors.toList())); + result = buildCScore(userMap,pids,u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d,releaseBugs.stream().filter(o->u.getAccount().equals(o.getBindUser())).collect(Collectors.toList()),testStoriesOf(u, cswcStoryList, pids, firstDayOfMonth, lastDayOfMonth)); } else if (u.getUserType() == UserType.UI) { result = buildUiScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d); } else if (u.getUserType() == UserType.XMJL || u.getUserType() == UserType.XMGLY || u.getUserType() == UserType.GSGC) { -// List list = projectService.list(new QueryWrapper().lambda().eq(ZtProject::getPm, result.getAccount())); + //CHG-037:项目经理得分全部走 buildXMJLScore(新 Excel scope 驱动),旧 programCount/任务管理/Bug管理/版本计划 附加计算废弃 result = buildXMJLScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d); - ZtCountQo ztCountQo = new ZtCountQo(); - BeanUtils.copyProperties(qo, ztCountQo); - List ztProducts = this.productService.listByIds(pids); - List perList = this.programCount(ztCountQo,ztProducts.stream().map(o->o.getProgram()).collect(Collectors.toList())); - - result = buildTaskManage(result, perList, userMap,firstDayOfMonth,lastDayOfMonth,pids); - result = buildBugManage(result, ztCountQo, perList, userMap); - - String versionPlanScore = versionPlanScore(ztCountQo, perList, userMap); - result.setVersionPlanFinishedRate(new BigDecimal(versionPlanScore)); - result.setSystemStabilityScore(BigDecimal.valueOf(10)); - result.setProfessionalSkillEnhancementScore(BigDecimal.valueOf(0)); } else if (u.getUserType() == UserType.XMZL) { result = buildXMZLScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d); } else if (u.getUserType() == UserType.CPJL) { //产品经理 result = buildCPJLScore(u, userMap , qo,approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d,pids); + } else if (u.getUserType() == UserType.YW) { + //运维工程师(CHG-037 新增分支) + result = buildYwScore(u, approvalList, firstDayOfMonth, lastDayOfMonth, taskList, d); + } + } finally { + com.sa.zentao.service.perf.AbstractPerfIndicatorCalculator.clearCurrentProgram(); } return result; @@ -2772,6 +3111,88 @@ public class IZtCountService { return teams.stream().map(o -> o.getAccount()).distinct().collect(Collectors.toList()); } + /** + * 轻量月度 KFZ 工时口径(仅供 storyBarChart/bugBarChart 柱状图,性能优化用): + * 复刻 newPerformanceCount 中图表实际消费的两个字段——KFZ 成员 allocationTime(分配工时 Σestimate, + * 口径同 buildKFZScore:taskListByEIdsAndDate 范围剔除 closed/cancel)与团队口径 examineTime + * (fillTeamExamine 人均达标工时:成员集合不变则人均值相同,KFZ 全员同值)。 + * 跳过与图表无关的岗位分流算分、bug/文档/审批明细等查询。对外结果口径不变,仅裁剪计算范围。 + * + * @param pids 产品集下产品 id 列表(调用方已查,避免月度循环内重复查询) + * @param firstDayOfMonth 当月首日 + * @param lastDayOfMonth 当月末日 + * @param d 月份(达标工时按该月工作天数计算,与原 countQo.date=firstDayOfMonth 同月) + * @param needExamine false 时跳过团队达标工时计算(bugBarChart 只用 allocationTime) + * @return KFZ 成员 account → [allocationTime, examineTime] + */ + private Map kfzMonthTimesLite(List pids, Date firstDayOfMonth, Date lastDayOfMonth, Date d, boolean needExamine) { + Map result = new HashMap<>(); + //成员集合口径与 newPerformanceCount 一致:产品→项目→当月窗口内执行→执行团队账号 + List projectproducts = this.projectproductService.list(new QueryWrapper().lambda() + .in(ZtProjectproduct::getProduct, pids)); + if (CollectionUtils.isEmpty(projectproducts)) { + return result; + } + List ztProjects = this.projectService.getExecutionsListByProjectAndDate( + projectproducts.stream().map(ZtProjectproduct::getProject).collect(Collectors.toList()), firstDayOfMonth, lastDayOfMonth); + if (CollectionUtils.isEmpty(ztProjects)) { + return result; + } + List teams = this.teamService.list(new QueryWrapper().lambda().eq(ZtTeam::getType, "execution") + .in(ZtTeam::getRoot, ztProjects.stream().map(ZtProject::getId).collect(Collectors.toList()))); + List accountIds = teams.stream().map(ZtTeam::getAccount).distinct().collect(Collectors.toList()); + if (CollectionUtils.isEmpty(accountIds)) { + return result; + } + Map userMap = this.userService.userMapByIds(accountIds); + //分配工时:按指派人预分组(剔除 closed/cancel 与空指派,替代逐用户全表 filter) + Map> tasksByAssignee = this.taskService.getNormalTaskList( + this.taskService.taskListByEIdsAndDate(firstDayOfMonth, lastDayOfMonth, pids)) + .stream().filter(o -> !StringUtils.isEmpty(o.getAssignedTo())) + .collect(Collectors.groupingBy(ZtTask::getAssignedTo)); + //团队口径人均达标工时:公式与 fillTeamExamine 完全一致;请假数据按姓名一次批量查出再分组(原逐人逐月跨库查询) + BigDecimal perCapita = BigDecimal.ZERO; + if (needExamine) { + List devs = accountIds.stream().map(userMap::get) + .filter(o -> o != null && o.getUserType() == UserType.KFZ && !"1".equals(o.getDeleted())) + .collect(Collectors.toList()); + if (!devs.isEmpty()) { + Map> approvalsByName = this.taskService.itApprovalsByNames( + devs.stream().map(ZtUser::getNickname).collect(Collectors.toList()), firstDayOfMonth, lastDayOfMonth) + .stream().collect(Collectors.groupingBy(ItApproval::getName)); + BigDecimal teamLeaveHours = BigDecimal.ZERO; + for (ZtUser dev : devs) { + BigDecimal hours = getApprovalTime(approvalsByName.getOrDefault(dev.getNickname(), Collections.emptyList()), d); + if (hours != null) { + teamLeaveHours = teamLeaveHours.add(hours); + } + } + int workDays = DateUtils.getWorkDaysInCurrentMonth(d); + // 团队总工作天数 = 工作天数×人数 − 团队请假折算天数(请假小时÷8);写死产品(如飞侠车服)基数用写死总工时÷5折算 + BigDecimal teamLeaveDays = teamLeaveHours.divide(BigDecimal.valueOf(8), 2, BigDecimal.ROUND_HALF_UP); + BigDecimal teamDays; + BigDecimal fixedTeamTime = fixedTeamMonthTime(accountIds, firstDayOfMonth, lastDayOfMonth); + if (fixedTeamTime != null) { + teamDays = fixedTeamTime.divide(BigDecimal.valueOf(5), 2, BigDecimal.ROUND_HALF_UP).subtract(teamLeaveDays); + } else { + teamDays = BigDecimal.valueOf((long) workDays * devs.size()).subtract(teamLeaveDays); + } + BigDecimal teamTargetTime = teamDays.multiply(BigDecimal.valueOf(5)); + perCapita = teamTargetTime.divide(BigDecimal.valueOf(devs.size()), 2, BigDecimal.ROUND_HALF_UP); + } + } + for (String account : accountIds) { + ZtUser u = userMap.get(account); + if (u == null || u.getUserType() != UserType.KFZ) { + continue; + } + BigDecimal allocation = floatBatchAdd(tasksByAssignee.getOrDefault(account, Collections.emptyList()) + .stream().map(ZtTask::getEstimate).collect(Collectors.toList())); + result.put(account, new BigDecimal[]{allocation, perCapita}); + } + return result; + } + public Map storyBarChart(ZtProjectQo qo) { Integer id = qo.getId(); Map map = new HashMap<>(); @@ -2799,6 +3220,8 @@ public class IZtCountService { List list = this.storyService.list(new QueryWrapper().lambda().in(ZtStory::getProduct, pIds) .ge(ZtStory::getEndDate, start).le(ZtStory::getEndDate, end)); + //全部产品 id(多部门成员实绩工时池范围)提出月度循环:原每月调用一次,登录态不变则值不变 + List allProductIds = null; for (int i = 0; i < 6; i++) { Date d = DateUtils.dateSubMonth(date, i); @@ -2819,37 +3242,43 @@ public class IZtCountService { } JSONObject obj = new JSONObject(); obj.put("storyCount", sList.size()); - //工作饱和度 分配工时 / 达标工时 - ZtCountQo countQo = new ZtCountQo(); - countQo.setProject(id); - countQo.setDate(firstDayOfMonth); - List performanceDTOS = this.newPerformanceCount(countQo); + //工作饱和度 分配工时 / 达标工时(轻量口径 kfzMonthTimesLite,结果与 newPerformanceCount 路径一致) + Map kfzTimes = kfzMonthTimesLite(pIds, firstDayOfMonth, lastDayOfMonth, firstDayOfMonth, true); BigDecimal examineTime = BigDecimal.ZERO; BigDecimal allocationTime = BigDecimal.ZERO; - Map userMap = this.userService.userMapByIds(null); List multipleUserList = getmultipleDepartProjectTeam(firstDayOfMonth, lastDayOfMonth); - //分配总工时 - List taskList = this.taskService.taskListByCondition(firstDayOfMonth, lastDayOfMonth, this.projectService.allProductList(),multipleUserList); - - - - for (PerformanceDTO performanceDTO : performanceDTOS) { - ZtUser ztUser = userMap.get(performanceDTO.getAccount()); - if (ztUser != null && ztUser.getUserType() == UserType.KFZ) { - BigDecimal totalTime = floatBatchAdd(this.taskService.getNormalTaskList(taskList.stream().filter(o->o.getAssignedTo().equals(ztUser.getAccount())).collect(Collectors.toList())).stream().map(o -> o.getConsumed()).collect(Collectors.toList())); - - if(multipleUserList.contains(ztUser.getAccount())){ - examineTime = - examineTime.add( - (BigDecimalUtils.isZero(performanceDTO.getAllocationTime())||BigDecimalUtils.isZero(totalTime))?BigDecimal.ZERO: - (performanceDTO.getAllocationTime().divide(totalTime,2,BigDecimal.ROUND_HALF_UP)) - .multiply(performanceDTO.getExamineTime()) - ); - }else{ - examineTime = examineTime.add(performanceDTO.getExamineTime()); - } - allocationTime = allocationTime.add(performanceDTO.getAllocationTime()); + //多部门成员当月实绩工时:按指派人预分组汇总(原逐用户 filter 全表;allProductList 提出循环按需取一次) + Map consumedByUser = new HashMap<>(); + if (!CollectionUtils.isEmpty(multipleUserList)) { + if (allProductIds == null) { + allProductIds = this.projectService.allProductList(); } + List monthConsumedTasks = this.taskService.taskListByCondition(firstDayOfMonth, lastDayOfMonth, allProductIds, multipleUserList); + for (ZtTask t : this.taskService.getNormalTaskList(monthConsumedTasks)) { + if (StringUtils.isEmpty(t.getAssignedTo()) || t.getConsumed() == null) { + continue; + } + consumedByUser.merge(t.getAssignedTo(), BigDecimal.valueOf(t.getConsumed()), BigDecimal::add); + } + } + + for (Map.Entry e : kfzTimes.entrySet()) { + String account = e.getKey(); + BigDecimal alloc = e.getValue()[0]; + BigDecimal examine = e.getValue()[1]; + if (multipleUserList.contains(account)) { + //与 floatBatchAdd 口径一致:累加后统一保留两位再参与除法 + BigDecimal totalTime = consumedByUser.getOrDefault(account, BigDecimal.ZERO).setScale(2, BigDecimal.ROUND_HALF_UP); + examineTime = + examineTime.add( + (BigDecimalUtils.isZero(alloc) || BigDecimalUtils.isZero(totalTime)) ? BigDecimal.ZERO : + (alloc.divide(totalTime, 2, BigDecimal.ROUND_HALF_UP)) + .multiply(examine) + ); + } else { + examineTime = examineTime.add(examine); + } + allocationTime = allocationTime.add(alloc); } obj.put("storyTime", BigDecimalUtils.isZero(examineTime) ? 0 : allocationTime.divide(examineTime, 2, BigDecimal.ROUND_UP)); map.put(DateUtils.formatDate(d, "yyyy-MM"), obj); @@ -2908,24 +3337,11 @@ public class IZtCountService { JSONObject obj = new JSONObject(); //bug密度 = bug数量 /分配工时 List devBugList = bugList.stream().filter(o -> o.getBugType().equals("dev")).collect(Collectors.toList()); - //分配工时 -// BigDecimal estimateAll = floatBatchAdd(taskList.stream().map(o -> o.getEstimate()).collect(Collectors.toList())); - ZtCountQo countQo = new ZtCountQo(); - countQo.setProject(id); - countQo.setDate(firstDayOfMonth); - List performanceDTOS = this.newPerformanceCount(countQo); + //分配总工时(轻量口径 kfzMonthTimesLite,结果与 newPerformanceCount 路径一致;本图不用达标工时,跳过团队请假计算) + Map kfzTimes = kfzMonthTimesLite(pIds, firstDayOfMonth, lastDayOfMonth, firstDayOfMonth, false); BigDecimal allocationTime = BigDecimal.ZERO; - Map userMap = this.userService.userMapByIds(null); - //分配总工时 - - - - for (PerformanceDTO performanceDTO : performanceDTOS) { - ZtUser ztUser = userMap.get(performanceDTO.getAccount()); - if (ztUser != null && ztUser.getUserType() == UserType.KFZ) { - - allocationTime = allocationTime.add(performanceDTO.getAllocationTime()); - } + for (BigDecimal[] v : kfzTimes.values()) { + allocationTime = allocationTime.add(v[0]); } BigDecimal density = BigDecimalUtils.isZero(allocationTime) ? BigDecimal.valueOf(devBugList.size()) : BigDecimal.valueOf(devBugList.size()).divide(allocationTime, 2, BigDecimal.ROUND_HALF_UP); @@ -3070,6 +3486,95 @@ public class IZtCountService { } + /** + * 工作量指标完成率(FR-012 三期) + * 已锁口径:完成率 = Σ(zt_story_month_workload 当月 workload 增量) ÷ (团队可用工作天数×5); + * 团队可用工作天数 = 当月工作天数 × 产出方人数(参照 workDetailsCount 团队解析,测试人员不计入产出方) + * @param month 月份 yyyy-MM(空则当月) + * @param projectId 产品集ID(zt_product.program,同 workDetailsCount 的 project 入参) + */ + public Map workloadRate(String month, Integer projectId) { + if (projectId == null) { + throw new BusinessException("projectId 不能为空"); + } + Date d = new Date(); + if (!StringUtils.isEmpty(month)) { + String[] parts = month.split("-"); + Calendar calendar = Calendar.getInstance(); + calendar.clear(); + calendar.set(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]) - 1, 1); + d = calendar.getTime(); + } + String monthStr = DateUtils.formatDate(d, "yyyy-MM"); + Date firstDayOfMonth = DateUtils.getFirstDayOfMonth(d); + Date lastDayOfMonth = DateUtils.getLastDayOfMonth(d); + + Map result = new HashMap<>(); + result.put("month", monthStr); + result.put("projectId", projectId); + + //产品集 → 产品(同 workDetailsCount 口径) + List productList = this.productService.list(new QueryWrapper().lambda().eq(ZtProduct::getProgram, projectId)); + if (CollectionUtils.isEmpty(productList)) { + result.put("rate", BigDecimal.ZERO); + result.put("remark", "产品集下无产品"); + return result; + } + List pids = productList.stream().map(o -> o.getId()).collect(Collectors.toList()); + List projectproducts = this.projectproductService.list(new QueryWrapper().lambda() + .in(ZtProjectproduct::getProduct, pids)); + if (CollectionUtils.isEmpty(projectproducts)) { + result.put("rate", BigDecimal.ZERO); + result.put("remark", "产品下无项目"); + return result; + } + List ztProjects = this.projectService.getExecutionsListByProjectAndDate( + projectproducts.stream().map(o -> o.getProject()).collect(Collectors.toList()), firstDayOfMonth, lastDayOfMonth); + if (CollectionUtils.isEmpty(ztProjects)) { + result.put("rate", BigDecimal.ZERO); + result.put("remark", "当月无迭代"); + return result; + } + List teamList = this.teamService.list(new QueryWrapper().lambda().eq(ZtTeam::getType, "execution") + .in(ZtTeam::getRoot, ztProjects.stream().map(o -> o.getId()).collect(Collectors.toList()))); + if (CollectionUtils.isEmpty(teamList)) { + result.put("rate", BigDecimal.ZERO); + result.put("remark", "迭代下无团队成员"); + return result; + } + List userList = this.userService.list(new QueryWrapper().lambda() + .in(ZtUser::getAccount, teamList.stream().map(o -> o.getAccount()).collect(Collectors.toList()))); + //测试人员不计入产出方 + long producerCount = userList.stream().filter(o -> o.getUserType() != UserType.CS).count(); + + //当月 workload 增量合计(zt_story_month_workload.workload_index 列即当月增量) + List storyList = this.storyService.list(new QueryWrapper().lambda() + .select(ZtStory::getId).in(ZtStory::getProduct, pids)); + BigDecimal totalWorkload = BigDecimal.ZERO; + if (!CollectionUtils.isEmpty(storyList)) { + List workloadList = this.storyMonthWorkloadService.list(new QueryWrapper().lambda() + .eq(ZtStoryMonthWorkload::getTime, monthStr) + .in(ZtStoryMonthWorkload::getStoryId, storyList.stream().map(o -> o.getId()).collect(Collectors.toList()))); + for (ZtStoryMonthWorkload workload : workloadList) { + if (workload.getWorkloadIndex() != null) { + totalWorkload = totalWorkload.add(workload.getWorkloadIndex()); + } + } + } + + int workDays = DateUtils.getWorkDaysInCurrentMonth(d); + BigDecimal denominator = BigDecimal.valueOf((long) workDays * 5L * Math.max(producerCount, 1)); + BigDecimal rate = producerCount == 0 ? BigDecimal.ZERO + : totalWorkload.multiply(BigDecimal.valueOf(100)).divide(denominator, 2, BigDecimal.ROUND_HALF_UP); + + result.put("totalWorkload", totalWorkload); + result.put("workDays", workDays); + result.put("producerCount", producerCount); + result.put("rate", rate); + return result; + } + + } diff --git a/src/main/java/com/sa/zentao/service/impl/ZtBugServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtBugServiceImpl.java index 6a5630b..948191f 100644 --- a/src/main/java/com/sa/zentao/service/impl/ZtBugServiceImpl.java +++ b/src/main/java/com/sa/zentao/service/impl/ZtBugServiceImpl.java @@ -526,7 +526,8 @@ public class ZtBugServiceImpl extends ServiceImpl implements ztBugs = this.baseMapper.selectList(new QueryWrapper().lambda().eq(ZtBug::getTask, task)); } if(story!=null&&story!=0){ - ztBugs = this.baseMapper.selectList(new QueryWrapper().lambda().eq(ZtBug::getStory, story)); + //CHG-067:入参是 toStory 的需求ID,关联查询应打 toStory 列(story 列全库未用) + ztBugs = this.baseMapper.selectList(new QueryWrapper().lambda().eq(ZtBug::getTostory, story)); } List ztBugDTOS =new ArrayList<>(); if(!CollectionUtils.isEmpty(ztBugs)){ diff --git a/src/main/java/com/sa/zentao/service/impl/ZtDocCheckServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtDocCheckServiceImpl.java new file mode 100644 index 0000000..87dcee0 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/impl/ZtDocCheckServiceImpl.java @@ -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; + +/** + *

+ * 大型需求文档齐备核查 服务实现类(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 维度) + *

+ * + * @author ai + * @since 2026-07-28 + */ +@Service +@Slf4j +public class ZtDocCheckServiceImpl extends ServiceImpl 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 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 generateMonthSnapshot(String month) { + if (StringUtils.isEmpty(month)) { + throw new BusinessException("月份不能为空"); + } + List 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 deductByAccount = new HashMap<>(); + if (!CollectionUtils.isEmpty(storyIds)) { + List rows = this.list(new QueryWrapper().lambda() + .eq(ZtDocCheck::getMonth, month).in(ZtDocCheck::getStoryId, storyIds)); + List stories = storyService.listByIds(storyIds); + Map storyMap = stories.stream().collect(Collectors.toMap(ZtStory::getId, o -> o, (a, b) -> a)); + Set 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 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 listMonthRows(String month) { + if (StringUtils.isEmpty(month)) { + return new ArrayList<>(); + } + return this.list(new QueryWrapper().lambda().eq(ZtDocCheck::getMonth, month)); + } + + @Override + public List> listMatrix(String month) { + List rows = listMonthRows(month); + Map> byStory = rows.stream().collect(Collectors.groupingBy(ZtDocCheck::getStoryId)); + List> result = new ArrayList<>(); + if (byStory.isEmpty()) { + return result; + } + List stories = storyService.listByIds(byStory.keySet()); + Map titleMap = stories.stream() + .collect(Collectors.toMap(ZtStory::getId, ZtStory::getTitle, (a, b) -> a)); + Map wiMap = new HashMap<>(); + for (ZtStoryExpand e : storyExpandService.list( + new QueryWrapper().lambda().in(ZtStoryExpand::getStoryId, byStory.keySet()))) { + wiMap.put(e.getStoryId(), e.getWorkloadIndex()); + } + for (Map.Entry> en : byStory.entrySet()) { + Map row = new HashMap<>(); + Map 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 rows = listMonthRows(month); + for (ZtDocCheck c : rows) { + c.setStatus("CONFIRMED"); + } + if (!rows.isEmpty()) { + this.updateBatchById(rows); + } + } + + /** + * 大型需求 = workload_index>20 且当月有完成度变化 + */ + public List listLargeStoryIds(String month) { + List monthRecords = storyMonthWorkloadService.list( + new QueryWrapper().lambda().eq(ZtStoryMonthWorkload::getTime, month)); + if (CollectionUtils.isEmpty(monthRecords)) { + return new ArrayList<>(); + } + Set monthStoryIds = monthRecords.stream().map(ZtStoryMonthWorkload::getStoryId).collect(Collectors.toSet()); + List expands = storyExpandService.list( + new QueryWrapper().lambda().in(ZtStoryExpand::getStoryId, monthStoryIds)); + List 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().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 wrapper = new QueryWrapper().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().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 rows = listMonthRows(month); + if (CollectionUtils.isEmpty(rows)) { + return; + } + Set storyIds = rows.stream().map(ZtDocCheck::getStoryId).collect(Collectors.toSet()); + List stories = storyService.listByIds(storyIds); + Map 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 rows, Map storyMap) { + List mine = rows.stream() + .filter(o -> { + ZtStory story = storyMap.get(o.getStoryId()); + return story != null && account.equals(story.getAssignedTo()); + }) + .collect(Collectors.toList()); + List 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().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; + } + +} diff --git a/src/main/java/com/sa/zentao/service/impl/ZtFileServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtFileServiceImpl.java index 5608878..7adead4 100644 --- a/src/main/java/com/sa/zentao/service/impl/ZtFileServiceImpl.java +++ b/src/main/java/com/sa/zentao/service/impl/ZtFileServiceImpl.java @@ -1,13 +1,21 @@ package com.sa.zentao.service.impl; 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.sa.zentao.conf.RiskUserThreadLocal; +import com.sa.zentao.dao.BusinessException; 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.mapper.ZtFileMapper; +import com.sa.zentao.mapper.ZtMeetingMapper; +import com.sa.zentao.mapper.ZtStoryMapper; import com.sa.zentao.service.IZtFileService; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +34,18 @@ import java.util.List; @Service public class ZtFileServiceImpl extends ServiceImpl 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 @Transactional public void updateFile(String fileIds, Integer objectId, FileTypes type) { @@ -50,4 +70,88 @@ public class ZtFileServiceImpl extends ServiceImpl impleme 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 wrapper = new UpdateWrapper().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().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); + } + } } diff --git a/src/main/java/com/sa/zentao/service/impl/ZtMeetingServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtMeetingServiceImpl.java index a9c562b..7afc95a 100644 --- a/src/main/java/com/sa/zentao/service/impl/ZtMeetingServiceImpl.java +++ b/src/main/java/com/sa/zentao/service/impl/ZtMeetingServiceImpl.java @@ -20,6 +20,7 @@ import com.sa.zentao.dao.BusinessException; import com.sa.zentao.dao.ZtMeetingDTO; import com.sa.zentao.dao.ZtStoryDTO; import com.sa.zentao.entity.ZtMeeting; +import com.sa.zentao.entity.ZtFile; import com.sa.zentao.entity.ZtProduct; import com.sa.zentao.entity.ZtStoryUser; import com.sa.zentao.entity.ZtUser; @@ -157,6 +158,7 @@ public class ZtMeetingServiceImpl extends ServiceImpl().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 @Transactional public void modify(ZtMeetingDTO dto) { @@ -182,6 +203,7 @@ public class ZtMeetingServiceImpl extends ServiceImpl + * 绩效指标规则配置 服务实现类(三期) + *

+ * + * @author ai + * @since 2026-07-28 + */ +@Service +public class ZtPerfConfigServiceImpl extends ServiceImpl 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().lambda() + .eq(ZtPerfConfig::getRole, role) + .eq(ZtPerfConfig::getItemKey, itemKey) + .eq(ZtPerfConfig::getEnabled, 1) + .last("LIMIT 1")); + if (config != null) { + return config; + } + } + // 回退:该指标任意一条启用配置(岗位未识别/未配置时兜底取规则参数) + List list = this.list(new QueryWrapper().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().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; + } + } +} diff --git a/src/main/java/com/sa/zentao/service/impl/ZtPerfReportServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtPerfReportServiceImpl.java new file mode 100644 index 0000000..2bd72d1 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/impl/ZtPerfReportServiceImpl.java @@ -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; + +/** + *

+ * 绩效报表(三期批2:报表引擎+接口) 服务实现类 + *

+ * + * @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 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 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 all = perfConfigService.list(new QueryWrapper().eq("enabled", 1)); + for (ZtPerfConfig c : all) { + if (StringUtils.isNotBlank(c.getRole())) { + roles.add(c.getRole()); + } + } + } + if (roles.isEmpty()) { + throw new BusinessException("未找到启用中的绩效规则配置"); + } + + List 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 configs = perfConfigService.list( + new QueryWrapper().eq("role", role).eq("enabled", 1)); + if (CollectionUtils.isEmpty(configs)) { + throw new BusinessException("岗位无启用中的绩效规则配置:" + role); + } + Map metrics = new HashMap<>(); + Map rawDetails = new HashMap<>(); + Set preScored = new HashSet<>(); + Map 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 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 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 list = monthScoreService.list(new QueryWrapper() + .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("月份与账号不能为空"); + } + } +} diff --git a/src/main/java/com/sa/zentao/service/impl/ZtProductServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtProductServiceImpl.java index 00345f8..c8cbd6e 100644 --- a/src/main/java/com/sa/zentao/service/impl/ZtProductServiceImpl.java +++ b/src/main/java/com/sa/zentao/service/impl/ZtProductServiceImpl.java @@ -145,12 +145,17 @@ public class ZtProductServiceImpl extends ServiceImpl!StringUtils.isEmpty(o.getPo())).map(o->o.getPo()).collect(Collectors.toList())); Map userMap = this.userService.userMapByIds(null); - List allStoryList = this.storyService.list(new QueryWrapper().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 allStoryList = this.storyService.list(new QueryWrapper().lambda() + .select(ZtStory::getId, ZtStory::getProduct, ZtStory::getStatus, ZtStory::getStage) + .in(ZtStory::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList()))); List allBugList = bugService.list(new QueryWrapper().lambda() + .select(ZtBug::getId, ZtBug::getProduct, ZtBug::getStatus) .ne(ZtBug::getStatus,"closed") .in(ZtBug::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList()))); - - List allUserStoryList = this.storyUserService.list(new QueryWrapper().lambda().in(ZtStoryUser::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList()))); + List allUserStoryList = this.storyUserService.list(new QueryWrapper().lambda() + .select(ZtStoryUser::getId, ZtStoryUser::getProduct, ZtStoryUser::getStatus) + .in(ZtStoryUser::getProduct, result.stream().map(o -> o.getId()).collect(Collectors.toList()))); for (ZtProductDTO p:result ) { List storyList = allStoryList.stream().filter(o->o.getProduct().equals(p.getId())).collect(Collectors.toList()); diff --git a/src/main/java/com/sa/zentao/service/impl/ZtProjectServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtProjectServiceImpl.java index bac4600..ddeb91d 100644 --- a/src/main/java/com/sa/zentao/service/impl/ZtProjectServiceImpl.java +++ b/src/main/java/com/sa/zentao/service/impl/ZtProjectServiceImpl.java @@ -1337,8 +1337,10 @@ public class ZtProjectServiceImpl extends ServiceImpl(); } + //仅取预计开始在本月的任务:下方按天循环只匹配 estStarted 落在当月的任务,历史任务对结果无贡献(性能优化,口径不变) List list = this.taskService.list(new QueryWrapper().lambda().select(SFunctionColums.taskColumes()) .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()))); if (CollectionUtils.isEmpty(list)) { return new HashMap<>(); @@ -1369,8 +1371,16 @@ public class ZtProjectServiceImpl extends ServiceImpl accountIds = teams.stream().map(o -> o.getAccount()).distinct().collect(Collectors.toList()); - List userList = this.userService.list(); - Map uMap = userList.stream().collect(Collectors.toMap(ZtUser::getAccount, O -> O)); + //按需取用户:团队成员 ∪ 任务相关人(原 userService.list() 全表扫描;不在用户表的账号行为不变,仍为 null 跳过) + Set 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 uMap = this.userService.userMapByIds(new ArrayList<>(userAccounts)); for (ZtTask t : list) { if (t.getDeadline() != null) { @@ -1398,17 +1408,16 @@ public class ZtProjectServiceImpl extends ServiceImpl> 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++) { Date d = DateUtils.dateAddDay(firstDayOfMonth, i); List> l = new ArrayList<>(); for (String s : accountIds) { ZtUser ztUser = uMap.get(s); -// List fList = efforts.stream().filter(o -> o.getAccount().equals(s)) -// .filter(o->DateUtils.formatDate(o.getDate()).equals(DateUtils.formatDate(d))) -// .collect(Collectors.toList()); - List 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()); + List taskList = taskByDayAccount.getOrDefault(DateUtils.formatDate(d) + "#" + s, Collections.emptyList()); for (ZtTask t:taskList ) { t.setAssignedToName(ztUser.getNickname()); @@ -1977,6 +1986,9 @@ public class ZtProjectServiceImpl extends ServiceImpl scoreMap=getAccountScoreMap(date,accountIds); + //CHG-074:月度达标工时(团队口径人均,与绩效弹窗 myWorkScore 一致)——在窗执行 KFZ 成员 (工作天数×人数−团队请假÷8)×5÷人数 + BigDecimal teamPerCapita = countService.teamExamineTime(accountIds, firstDayOfMonth, lastDayOfMonth, date); + for (String account :accountIds) { ZtUser ztUser = uMap.get(account); @@ -2004,11 +2016,11 @@ public class ZtProjectServiceImpl extends ServiceImpl 0 ? teamPerCapita : dto.getExamineTime()); - //工作饱和度 - d.setSaturation(BigDecimalUtils.isZero(d.getHaveTime())?BigDecimal.valueOf(1):BigDecimalUtils.isZero(d.getWorkTime())?BigDecimal.ZERO:d.getStoryTotalTime().divide(d.getHaveTime(),2,BigDecimal.ROUND_UP)); + //工作饱和度(CHG-074:分子=实绩工时 consumed、分母=团队口径达标工时,与绩效弹窗一致) + 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")) d.setTaskCount(BigDecimal.valueOf(taskList.stream().collect(Collectors.toList()).size())); diff --git a/src/main/java/com/sa/zentao/service/impl/ZtStoryExpandServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtStoryExpandServiceImpl.java index 40a39cd..01d4736 100644 --- a/src/main/java/com/sa/zentao/service/impl/ZtStoryExpandServiceImpl.java +++ b/src/main/java/com/sa/zentao/service/impl/ZtStoryExpandServiceImpl.java @@ -2,6 +2,9 @@ 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.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.ZtStoryMonthWorkload; import com.sa.zentao.mapper.ZtStoryExpandMapper; @@ -35,6 +38,11 @@ public class ZtStoryExpandServiceImpl extends ServiceImpl impl } - List bugList = this.bugService.list(new QueryWrapper().lambda().eq(ZtBug::getStory, i).ne(ZtBug::getStatus, "closed")); + //CHG-067:需求关联 Bug 走 toStory 列(story 列全库未用)——关闭需求时联动关闭其未关闭 Bug + List bugList = this.bugService.list(new QueryWrapper().lambda().eq(ZtBug::getTostory, i).ne(ZtBug::getStatus, "closed")); for (ZtBug bug : bugList) { ZtBugQo qo = new ZtBugQo(); qo.setId(bug.getId()); diff --git a/src/main/java/com/sa/zentao/service/impl/ZtStoryUserServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtStoryUserServiceImpl.java index 2bb0e87..5563ad5 100644 --- a/src/main/java/com/sa/zentao/service/impl/ZtStoryUserServiceImpl.java +++ b/src/main/java/com/sa/zentao/service/impl/ZtStoryUserServiceImpl.java @@ -392,6 +392,7 @@ public class ZtStoryUserServiceImpl extends ServiceImpl o.getId() + ":" + o.getTitle()).collect(Collectors.joining(","))); d.setSList(CollectionUtils.isEmpty(ztStories) ? null : BeanCopyUtil.copyListProperties(ztStories, ZtStoryDTO::new)); } + d.setExecNames(buildExecNames(d.getExecList())); d.setLinkUrl(url); } } @@ -449,6 +450,24 @@ public class ZtStoryUserServiceImpl extends ServiceImpl 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 @Transactional public void changeStatus(ZtStoryUserDTO dto) { @@ -564,6 +583,9 @@ public class ZtStoryUserServiceImpl extends ServiceImpl tasks = this.storyUserTaskService.list(new QueryWrapper().lambda().eq(ZtStoryUserTask::getUserStoryId, dto.getId())); diff --git a/src/main/java/com/sa/zentao/service/impl/ZtTaskServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtTaskServiceImpl.java index 834879d..5285a56 100644 --- a/src/main/java/com/sa/zentao/service/impl/ZtTaskServiceImpl.java +++ b/src/main/java/com/sa/zentao/service/impl/ZtTaskServiceImpl.java @@ -27,6 +27,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; import java.math.BigDecimal; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.*; import java.util.stream.Collectors; @@ -328,6 +330,14 @@ public class ZtTaskServiceImpl extends ServiceImpl impleme return this.baseMapper.itApprovalByUserName(s, firstDayOfMonth, lastDayOfMonth); } + @Override + public List itApprovalsByNames(List names, Date firstDayOfMonth, Date lastDayOfMonth) { + if (CollectionUtils.isEmpty(names)) { + return new ArrayList<>(); + } + return this.baseMapper.itApprovalsByNames(names, firstDayOfMonth, lastDayOfMonth); + } + @Override public List taskListByEIdsAndDate(Date firstDayOfMonth, Date lastDayOfMonth, List pids) { if (CollectionUtils.isEmpty(pids)) { @@ -1352,6 +1362,167 @@ public class ZtTaskServiceImpl extends ServiceImpl impleme } + @Override + @Transactional + public Map 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 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().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().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 existList = this.list(new QueryWrapper().lambda().eq(ZtTask::getStory, dto.getStoryId())); + Set 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 existIds = existList.stream().map(ZtTask::getId).collect(Collectors.toSet()); + + List skipped = new ArrayList<>(); + List createdDescs = new ArrayList<>(); + List taskDtos = new ArrayList<>(); + Set 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().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 taskIds = new ArrayList<>(); + if (!taskDtos.isEmpty()) { + ZtTaskDTO batchDto = new ZtTaskDTO(); + batchDto.setList(taskDtos); + this.batchAddTask(batchDto); + //batchAddTask无返回值:按 id 差集 + name#type 精确匹配取本次新建任务 id + List createdList = this.list(new QueryWrapper().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 result = new HashMap<>(); + result.put("created", taskIds.size()); + result.put("taskIds", taskIds); + result.put("skipped", skipped); + return result; + } + @Override @Transactional public void approval(ZtTaskDTO dto) { diff --git a/src/main/java/com/sa/zentao/service/impl/ZtUserServiceImpl.java b/src/main/java/com/sa/zentao/service/impl/ZtUserServiceImpl.java index cd98ca3..fa73090 100644 --- a/src/main/java/com/sa/zentao/service/impl/ZtUserServiceImpl.java +++ b/src/main/java/com/sa/zentao/service/impl/ZtUserServiceImpl.java @@ -354,6 +354,10 @@ public class ZtUserServiceImpl extends ServiceImpl impleme } ZtUser ztUser = new ZtUser(); BeanUtils.copyProperties(user,ztUser); + //开发方向(CHG-038):仅 KFZ 保留,其余属性置空 + if (ztUser.getUserType() != com.sa.zentao.enums.UserType.KFZ) { + ztUser.setDevDirection(null); + } List productList = user.getProductList(); if(!CollectionUtils.isEmpty(productList)){ ztUser.setProductIds(StringUtils.join(productList,",")); @@ -401,6 +405,8 @@ public class ZtUserServiceImpl extends ServiceImpl impleme ztUser.setPassword(user.getPassword()); 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.setPhone(user.getPhone()); if(!CollectionUtils.isEmpty(user.getProductList())){ diff --git a/src/main/java/com/sa/zentao/service/perf/AbstractDocReadyCalculator.java b/src/main/java/com/sa/zentao/service/perf/AbstractDocReadyCalculator.java new file mode 100644 index 0000000..6175171 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/AbstractDocReadyCalculator.java @@ -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 docTypes(); + + /** + * 每缺失一份扣分(缺省值,rule_json.deductPerStep 可覆盖) + */ + protected abstract int defaultDeductPerMissing(); + + /** + * 该核查记录是否归属此账号 + */ + protected abstract boolean ownRow(ZtDocCheck row, Map storyMap, String account); + + @Override + protected BigDecimal doCompute(String account, String month, JSONObject rule) { + List rows = docCheckService.listMonthRows(month); + if (CollectionUtils.isEmpty(rows)) { + rawDetail("当月无核查记录"); + return FULL_SCORE; + } + Set types = docTypes(); + List 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 storyIds = missing.stream().map(ZtDocCheck::getStoryId).collect(Collectors.toSet()); + Map 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))); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/AbstractOpsCountCalculator.java b/src/main/java/com/sa/zentao/service/perf/AbstractOpsCountCalculator.java new file mode 100644 index 0000000..b424058 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/AbstractOpsCountCalculator.java @@ -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)))); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/AbstractPerfIndicatorCalculator.java b/src/main/java/com/sa/zentao/service/perf/AbstractPerfIndicatorCalculator.java new file mode 100644 index 0000000..0ae4742 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/AbstractPerfIndicatorCalculator.java @@ -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 RAW_DETAIL = new ThreadLocal<>(); + + /** + * CHG-053 当前选中的产品集(program id,月报下拉):myWorkScore 入口设置, + * 各项目口径指标按该产品集过滤;null = 按本人项目(旧行为)。 + */ + private static final ThreadLocal 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 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); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/AbstractTaskOnTimeRateCalculator.java b/src/main/java/com/sa/zentao/service/perf/AbstractTaskOnTimeRateCalculator.java new file mode 100644 index 0000000..6ff1e77 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/AbstractTaskOnTimeRateCalculator.java @@ -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 wrapper = new QueryWrapper().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 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); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/AbstractWeightedBugCalculator.java b/src/main/java/com/sa/zentao/service/perf/AbstractWeightedBugCalculator.java new file mode 100644 index 0000000..03816d1 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/AbstractWeightedBugCalculator.java @@ -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 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 bugs = bugService.list(new QueryWrapper().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 stories = storyService.list(new QueryWrapper().lambda() + .ge(ZtStory::getReleaseddate, firstDayOfMonth(month)) + .le(ZtStory::getReleaseddate, lastDayOfMonth(month))); + boolean projectScoped = isProjectScopedRole(account); + if (projectScoped) { + Set 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 storyIds = stories.stream().map(ZtStory::getId).collect(Collectors.toList()); + BigDecimal totalEstimate = sumFloat(taskService.list(new QueryWrapper().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 bugs = bugService.list(new QueryWrapper().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 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 bugs) { + return bugs.stream().filter(o -> o.getSeverity() != null && o.getSeverity() == 1).count(); + } + + private long countNormal(List bugs) { + return bugs.stream().filter(o -> o.getSeverity() != null + && (o.getSeverity() == 2 || o.getSeverity() == 3 || o.getSeverity() == 4)).count(); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/AbstractWorkloadRateCalculator.java b/src/main/java/com/sa/zentao/service/perf/AbstractWorkloadRateCalculator.java new file mode 100644 index 0000000..31e1e9a --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/AbstractWorkloadRateCalculator.java @@ -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 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 projectExecs(String account, String month) { + List teams = teamService.list(new QueryWrapper().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().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 projectScopeStoryIds(String account, String month) { + Set productIds = currentProgramProductIds(); + if (productIds == null) { + List execs = projectExecs(account, month); + if (execs.isEmpty()) { + return Collections.emptySet(); + } + List eps = executionprojectService.list(new QueryWrapper().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 pps = projectproductService.list(new QueryWrapper().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().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 execs; + Set 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 members = teamService.list(new QueryWrapper().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 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().lambda() + .eq(ZtUser::getUserType, UserType.KFZ) + .in(ZtUser::getAccount, accounts)); + } + + /** + * CHG-053 当前选中产品集(program id)→ 产品 id 集;未选中(null)返回 null + */ + protected Set currentProgramProductIds() { + Integer program = currentProgram(); + if (program == null) { + return null; + } + return productService.list(new QueryWrapper().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 execsOfProducts(Set productIds, String month) { + List pps = projectproductService.list(new QueryWrapper().lambda() + .in(com.sa.zentao.entity.ZtProjectproduct::getProduct, productIds)); + if (pps.isEmpty()) { + return Collections.emptyList(); + } + List eps = executionprojectService.list(new QueryWrapper().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().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 records = storyMonthWorkloadService.list( + new QueryWrapper().lambda().eq(ZtStoryMonthWorkload::getTime, month)); + Set 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 productIds) { + if (CollectionUtils.isEmpty(productIds) || FixedTeamMonthTime.MAP.isEmpty()) { + return null; + } + List 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().lambda().eq(ZtUser::getUserType, UserType.KFZ)); + return (int) count; + } + + protected Set toStoryIds(List expands) { + return expands.stream().map(com.sa.zentao.entity.ZtStoryExpand::getStoryId).collect(Collectors.toSet()); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/BugDensityCalculator.java b/src/main/java/com/sa/zentao/service/perf/BugDensityCalculator.java new file mode 100644 index 0000000..3c5d04b --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/BugDensityCalculator.java @@ -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 tasks = taskService.list(new QueryWrapper().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 taskIds = tasks.stream().map(ZtTask::getId).collect(Collectors.toList()); + long bugCount = bugService.count(new QueryWrapper().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); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/CodeQualityCalculator.java b/src/main/java/com/sa/zentao/service/perf/CodeQualityCalculator.java new file mode 100644 index 0000000..4c53018 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/CodeQualityCalculator.java @@ -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 stories = storyService.list(new QueryWrapper().lambda() + .eq(ZtStory::getAssignedTo, account)); + if (CollectionUtils.isEmpty(stories)) { + // 无负责需求:无审查对象,不扣分 + return FULL_SCORE; + } + List files = fileService.list(new QueryWrapper().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; + } + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/DefectFindRateCalculator.java b/src/main/java/com/sa/zentao/service/perf/DefectFindRateCalculator.java new file mode 100644 index 0000000..52936d8 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/DefectFindRateCalculator.java @@ -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 bugs = bugService.list(new QueryWrapper().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 storyIds = bugs.stream().map(ZtBug::getTostory).filter(o -> o != null).distinct().collect(Collectors.toList()); + BigDecimal devHours = BigDecimal.ZERO; + if (!CollectionUtils.isEmpty(storyIds)) { + List devTasks = taskService.list(new QueryWrapper().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); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/DocReadyScoreCalculator.java b/src/main/java/com/sa/zentao/service/perf/DocReadyScoreCalculator.java new file mode 100644 index 0000000..c3bf079 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/DocReadyScoreCalculator.java @@ -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 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 records = storyMonthWorkloadService.list( + new QueryWrapper().lambda().eq(ZtStoryMonthWorkload::getTime, month)); + if (CollectionUtils.isEmpty(records)) { + rawDetail("当月无大型需求"); + return FULL_SCORE; + } + Set monthStoryIds = records.stream().map(ZtStoryMonthWorkload::getStoryId).collect(Collectors.toSet()); + Set largeIds = storyExpandService.list(new QueryWrapper().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 scopeIds = largeIds; + if (isProjectManager(account)) { + Set projectStoryIds = projectScopeStoryIds(account, month); + scopeIds = largeIds.stream().filter(projectStoryIds::contains).collect(Collectors.toSet()); + if (scopeIds.isEmpty()) { + rawDetail("当月无大型需求(项目范围)"); + return FULL_SCORE; + } + } + Map 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 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; + } + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/FixedTeamMonthTime.java b/src/main/java/com/sa/zentao/service/perf/FixedTeamMonthTime.java new file mode 100644 index 0000000..eccda74 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/FixedTeamMonthTime.java @@ -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 MAP; + + static { + Map m = new HashMap<>(); + // 飞侠车服(外包团队合同包月工时) + m.put("飞侠车服", BigDecimal.valueOf(525)); + MAP = Collections.unmodifiableMap(m); + } + + private FixedTeamMonthTime() { + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/MdContentResolver.java b/src/main/java/com/sa/zentao/service/perf/MdContentResolver.java new file mode 100644 index 0000000..0b2523d --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/MdContentResolver.java @@ -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}; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/OnlineBugRateCalculator.java b/src/main/java/com/sa/zentao/service/perf/OnlineBugRateCalculator.java new file mode 100644 index 0000000..3a5ce89 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/OnlineBugRateCalculator.java @@ -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"; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/OpsBackupCalculator.java b/src/main/java/com/sa/zentao/service/perf/OpsBackupCalculator.java new file mode 100644 index 0000000..ab12532 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/OpsBackupCalculator.java @@ -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().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; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/OpsInspectCalculator.java b/src/main/java/com/sa/zentao/service/perf/OpsInspectCalculator.java new file mode 100644 index 0000000..2e4f2ab --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/OpsInspectCalculator.java @@ -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().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; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/OpsMajorTaskCalculator.java b/src/main/java/com/sa/zentao/service/perf/OpsMajorTaskCalculator.java new file mode 100644 index 0000000..92c0d5a --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/OpsMajorTaskCalculator.java @@ -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 tasks = ywTaskService.list(new QueryWrapper().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); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/OpsMonitorCalculator.java b/src/main/java/com/sa/zentao/service/perf/OpsMonitorCalculator.java new file mode 100644 index 0000000..1cb9c56 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/OpsMonitorCalculator.java @@ -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().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; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/PerfIndicatorCalculator.java b/src/main/java/com/sa/zentao/service/perf/PerfIndicatorCalculator.java new file mode 100644 index 0000000..025e550 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/PerfIndicatorCalculator.java @@ -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; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/PerfMonthScope.java b/src/main/java/com/sa/zentao/service/perf/PerfMonthScope.java new file mode 100644 index 0000000..106d8b2 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/PerfMonthScope.java @@ -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 items = new ArrayList<>(); + + private List bonus = new ArrayList<>(); + + private List 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; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/PerfScoreEngine.java b/src/main/java/com/sa/zentao/service/perf/PerfScoreEngine.java new file mode 100644 index 0000000..71da91e --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/PerfScoreEngine.java @@ -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 支持的规则类型: + *
    + *
  • 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"}
  • + *
  • threshold 分档:{"type":"threshold","bands":[{"min":100,"score":100},{"min":90,"score":40},{"min":0,"score":0}]} + * raw ≥ min 的第一档得分(bands 按 min 从大到小匹配)。
  • + *
  • manual 人工评分:{"type":"manual"},score 缺省为 null(等待人工录入,不计入总分)。
  • + *
  • 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;全部条件满足则加分。
  • + *
+ * + * 特殊返还逻辑(如后端 Bug 密度连续 3 月达标返还半年扣分)作为 linear 规则的扩展参数: + * {"refund":{"threshold":15,"direction":"lte","consecutiveMonths":3}} + * 引擎通过 context 取数判定: + *
    + *
  • context["metricHistory"]:Map<itemKey, List<Double>> 历史月份原始值(近→远,不含本月)
  • + *
  • context["deductedPoints"]:Map<itemKey, Double> 返还窗口内该项累计扣分
  • + *
  • context["preScoredItems"]:Set<itemKey> 指标值已是 0~100 得分(批1 PerfIndicatorCalculator 产出),引擎直接采信不再套规则
  • + *
+ * 本月 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 configs, Map metrics, + Map 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 metrics, + Map 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) 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 configs, Map 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 configs, Map metrics, + Map context, PerfMonthScope scope) { + if (context == null) { + return; + } + Map> history = (Map>) context.get(CTX_METRIC_HISTORY); + Map deductedPoints = (Map) 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 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 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(); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/ProductDefectRateCalculator.java b/src/main/java/com/sa/zentao/service/perf/ProductDefectRateCalculator.java new file mode 100644 index 0000000..a8cfc38 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/ProductDefectRateCalculator.java @@ -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"; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/ProjectPunctualityRateCalculator.java b/src/main/java/com/sa/zentao/service/perf/ProjectPunctualityRateCalculator.java new file mode 100644 index 0000000..4dbcf9b --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/ProjectPunctualityRateCalculator.java @@ -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 stories = storyService.list(new QueryWrapper().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); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/TaskOnTimeRateCalculator.java b/src/main/java/com/sa/zentao/service/perf/TaskOnTimeRateCalculator.java new file mode 100644 index 0000000..701538e --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/TaskOnTimeRateCalculator.java @@ -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; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/TestDocReadyCalculator.java b/src/main/java/com/sa/zentao/service/perf/TestDocReadyCalculator.java new file mode 100644 index 0000000..e838138 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/TestDocReadyCalculator.java @@ -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 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 storyMap, String account) { + List expands = storyExpandService.list(new QueryWrapper().lambda() + .eq(ZtStoryExpand::getStoryId, row.getStoryId()) + .like(ZtStoryExpand::getTestPerson, account) + .last("LIMIT 1")); + return !CollectionUtils.isEmpty(expands); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/TestPlanOnTimeRateCalculator.java b/src/main/java/com/sa/zentao/service/perf/TestPlanOnTimeRateCalculator.java new file mode 100644 index 0000000..2c1af03 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/TestPlanOnTimeRateCalculator.java @@ -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"; + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/VersionPlanRateCalculator.java b/src/main/java/com/sa/zentao/service/perf/VersionPlanRateCalculator.java new file mode 100644 index 0000000..f4f17d1 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/VersionPlanRateCalculator.java @@ -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 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 stories = storyService.list(new QueryWrapper().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 projectStoryIds = projectScopeStoryIds(account, month); + stories = stories.stream().filter(o -> projectStoryIds.contains(o.getId())).collect(Collectors.toList()); + if (stories.isEmpty()) { + rawDetail("当月无发布需求(项目范围)"); + return BigDecimal.ZERO; + } + } + Map indexMap = storyExpandService.list(new QueryWrapper().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; + } + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/WorkSaturationCalculator.java b/src/main/java/com/sa/zentao/service/perf/WorkSaturationCalculator.java new file mode 100644 index 0000000..0e61b0d --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/WorkSaturationCalculator.java @@ -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 efforts = effortService.list(new QueryWrapper().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 devs = userService.list(new QueryWrapper().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 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); + } +} diff --git a/src/main/java/com/sa/zentao/service/perf/WorkloadRatePrdCalculator.java b/src/main/java/com/sa/zentao/service/perf/WorkloadRatePrdCalculator.java new file mode 100644 index 0000000..eb2424c --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/WorkloadRatePrdCalculator.java @@ -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 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().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 + */ +} diff --git a/src/main/java/com/sa/zentao/service/perf/WorkloadRateTeamCalculator.java b/src/main/java/com/sa/zentao/service/perf/WorkloadRateTeamCalculator.java new file mode 100644 index 0000000..b68ff17 --- /dev/null +++ b/src/main/java/com/sa/zentao/service/perf/WorkloadRateTeamCalculator.java @@ -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 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(); + } +} diff --git a/src/main/java/com/sa/zentao/task/SpringTaskJob.java b/src/main/java/com/sa/zentao/task/SpringTaskJob.java index f8347b1..59b7993 100644 --- a/src/main/java/com/sa/zentao/task/SpringTaskJob.java +++ b/src/main/java/com/sa/zentao/task/SpringTaskJob.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.sa.zentao.entity.ZtCronDevops; import com.sa.zentao.service.IZtCronDevopsService; +import com.sa.zentao.service.IZtDocCheckService; import com.sa.zentao.service.IZtYwTaskService; import com.sa.zentao.utils.Constant; import com.sa.zentao.utils.DateUtils; @@ -31,6 +32,9 @@ public class SpringTaskJob { @Autowired private IZtYwTaskService ywTaskService; + @Autowired + private IZtDocCheckService docCheckService; + //每天凌晨一点去 @Scheduled(cron ="0 0 0 * * ?") 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")); + + } + } \ No newline at end of file diff --git a/src/main/java/com/sa/zentao/utils/PerfScoreRules.java b/src/main/java/com/sa/zentao/utils/PerfScoreRules.java new file mode 100644 index 0000000..f32ec54 --- /dev/null +++ b/src/main/java/com/sa/zentao/utils/PerfScoreRules.java @@ -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; + } +} diff --git a/src/main/resources/mapper/ZtStoryExpandMapper.xml b/src/main/resources/mapper/ZtStoryExpandMapper.xml index d651de7..b518af4 100644 --- a/src/main/resources/mapper/ZtStoryExpandMapper.xml +++ b/src/main/resources/mapper/ZtStoryExpandMapper.xml @@ -22,6 +22,7 @@ + + +