From 75b19eb6ab653ea189656ec0d7f4e128d378517f Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Tue, 7 Jul 2026 10:10:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(report):=20=E6=96=B0=E5=A2=9E=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E9=94=9A=E7=82=B9=E6=A8=A1=E6=9D=BF=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8F=8A=E6=95=B0=E6=8D=AE=E5=BA=93=E8=BF=9E=E6=8E=A5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新数据库连接URL从pqs910到pqs91001 - 在BaseReportKeyEnum中新增ARRIVED_DATE_C、TEST_DATE_C和YEAR_MONTH_DAY_UP枚举值 - 在ItemReportKeyEnum中新增标准电压电流功率等指标锚点相关枚举值 - 新增ItemAnchorAssembler工具类实现指标锚点模板的表格装配功能 - 实现云南检定报告的指标锚点新型模板处理流程 - 添加中文数字日期格式化功能支持 - 实现有功功率特例的数据映射处理 - 支持不平衡度类表格数据的生成处理 --- .../pojo/constant/ItemAnchorConstant.java | 68 ++++ .../report/pojo/enums/BaseReportKeyEnum.java | 3 + .../report/pojo/enums/ItemReportKeyEnum.java | 15 +- .../service/impl/PqReportServiceImpl.java | 168 ++++++++- .../report/util/ItemAnchorAssembler.java | 294 +++++++++++++++ .../report/util/ItemAnchorSectionUtil.java | 276 ++++++++++++++ .../service/impl/ResultServiceImpl.java | 35 ++ .../result/util/PowerResultAssembler.java | 342 ++++++++++++++++++ .../result/util/UnbalanceResultAssembler.java | 166 +++++++++ .../pojo/constant/ItemAnchorConstantTest.java | 45 +++ .../service/impl/PqReportServiceImplTest.java | 25 ++ .../report/util/Docx4jUtilTextHelper.java | 32 ++ .../report/util/ItemAnchorAssemblerTest.java | 320 ++++++++++++++++ .../util/ItemAnchorSectionUtilTest.java | 254 +++++++++++++ .../result/util/PowerResultAssemblerTest.java | 173 +++++++++ .../util/UnbalanceResultAssemblerTest.java | 92 +++++ entrance/src/main/resources/application.yml | 2 +- .../service/SimAndDigNonHarmonicService.java | 6 + .../impl/SimAndDigNonHarmonicServiceImpl.java | 15 + 19 files changed, 2328 insertions(+), 3 deletions(-) create mode 100644 detection/src/main/java/com/njcn/gather/report/pojo/constant/ItemAnchorConstant.java create mode 100644 detection/src/main/java/com/njcn/gather/report/util/ItemAnchorAssembler.java create mode 100644 detection/src/main/java/com/njcn/gather/report/util/ItemAnchorSectionUtil.java create mode 100644 detection/src/main/java/com/njcn/gather/result/util/PowerResultAssembler.java create mode 100644 detection/src/main/java/com/njcn/gather/result/util/UnbalanceResultAssembler.java create mode 100644 detection/src/test/java/com/njcn/gather/report/pojo/constant/ItemAnchorConstantTest.java create mode 100644 detection/src/test/java/com/njcn/gather/report/service/impl/PqReportServiceImplTest.java create mode 100644 detection/src/test/java/com/njcn/gather/report/util/Docx4jUtilTextHelper.java create mode 100644 detection/src/test/java/com/njcn/gather/report/util/ItemAnchorAssemblerTest.java create mode 100644 detection/src/test/java/com/njcn/gather/report/util/ItemAnchorSectionUtilTest.java create mode 100644 detection/src/test/java/com/njcn/gather/result/util/PowerResultAssemblerTest.java create mode 100644 detection/src/test/java/com/njcn/gather/result/util/UnbalanceResultAssemblerTest.java diff --git a/detection/src/main/java/com/njcn/gather/report/pojo/constant/ItemAnchorConstant.java b/detection/src/main/java/com/njcn/gather/report/pojo/constant/ItemAnchorConstant.java new file mode 100644 index 00000000..d688282b --- /dev/null +++ b/detection/src/main/java/com/njcn/gather/report/pojo/constant/ItemAnchorConstant.java @@ -0,0 +1,68 @@ +package com.njcn.gather.report.pojo.constant; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * "指标锚点"新型模板的书签命名空间常量。 + * 设计文档:docs/superpowers/specs/2026-07-02-item-anchor-report-template-design.md §3.1 + * + * @author hongawen + */ +public interface ItemAnchorConstant { + + /** 数据锚点书签前缀:item_{scriptCode},锚点处按回路插入检测数据表 */ + String ITEM_PREFIX = "item_"; + + /** 区段书签前缀:sect_{scriptCode} / sect_{GRP_XXX},指标无数据时整段删除 */ + String SECT_PREFIX = "sect_"; + + /** + * 父节 → 子节 scriptCode 映射;一个父节的全部子节都隐藏时,父节(组标题+组内公共文字)一并隐藏。 + * key 与模板中 sect_GRP_* 书签名的 GRP_* 部分一致。 + */ + Map> GRP_CHILDREN = buildGrpChildren(); + + /** + * 已知指标 scriptCode 白名单(规范大写,即云南模板 11 个锚点)。 + * 分流与锚点解析以此为准:白名单外的 item_* 书签一律视为无效,防止老模板中 + * 作者手工遗留的同前缀书签(如 item_1)把整份报告误路由进新路径。 + */ + Set KNOWN_SCRIPT_CODES = buildKnownScriptCodes(); + + static Map> buildGrpChildren() { + Map> map = new LinkedHashMap<>(); + map.put("GRP_FUND", Arrays.asList("V", "I", "P", "ANGLE")); + map.put("GRP_UNB", Arrays.asList("IMBV", "IMBA")); + map.put("GRP_HARM", Arrays.asList("HV", "HI", "HP")); + return Collections.unmodifiableMap(map); + } + + static Set buildKnownScriptCodes() { + return Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList( + "V", "I", "P", "ANGLE", "FREQ", "F", "IMBV", "IMBA", "HV", "HI", "HP"))); + } + + /** + * 从书签名解析指标锚点的 scriptCode。 + * + * @param bookmarkName 书签名 + * @return 规范大写 scriptCode(scriptMap / 模板 H5 分组 key 均为大写); + * 非 item_ 前缀、空 code 或不在 {@link #KNOWN_SCRIPT_CODES} 白名单内返回 null + */ + static String resolveItemScriptCode(String bookmarkName) { + if (bookmarkName == null || bookmarkName.length() <= ITEM_PREFIX.length()) { + return null; + } + if (!bookmarkName.substring(0, ITEM_PREFIX.length()).equalsIgnoreCase(ITEM_PREFIX)) { + return null; + } + String code = bookmarkName.substring(ITEM_PREFIX.length()).toUpperCase(); + return KNOWN_SCRIPT_CODES.contains(code) ? code : null; + } +} diff --git a/detection/src/main/java/com/njcn/gather/report/pojo/enums/BaseReportKeyEnum.java b/detection/src/main/java/com/njcn/gather/report/pojo/enums/BaseReportKeyEnum.java index c027b54b..9c0a8d02 100644 --- a/detection/src/main/java/com/njcn/gather/report/pojo/enums/BaseReportKeyEnum.java +++ b/detection/src/main/java/com/njcn/gather/report/pojo/enums/BaseReportKeyEnum.java @@ -26,12 +26,15 @@ public enum BaseReportKeyEnum { MANUFACTURER("manufacturer","设备厂家、制造厂商"), SAMPLE_ID("sampleId","样品编号"), ARRIVED_DATE("arrivedDate","收样日期"), + ARRIVED_DATE_C("arrivedDateC","收样日期"), TEST_DATE("testDate","检测日期"), + TEST_DATE_C("testDateC","检测日期"), INSPECTOR("inspector","检测员"), YEAR("year","年份"), MONTH("month","月份"), DAY("day","日"), YEAR_MONTH_DAY("year-month-day","年-月-日"), + YEAR_MONTH_DAY_UP("year-month-day-up","中文数字 年-月-日"), REPORT_DATE("reportDate","年-月-日"), GD_NAME("gdName","供电部门"), SUB_NAME("subName","变电站"), diff --git a/detection/src/main/java/com/njcn/gather/report/pojo/enums/ItemReportKeyEnum.java b/detection/src/main/java/com/njcn/gather/report/pojo/enums/ItemReportKeyEnum.java index 4c12055e..616e187f 100644 --- a/detection/src/main/java/com/njcn/gather/report/pojo/enums/ItemReportKeyEnum.java +++ b/detection/src/main/java/com/njcn/gather/report/pojo/enums/ItemReportKeyEnum.java @@ -45,7 +45,20 @@ public enum ItemReportKeyEnum { RESULT_B("resultB", "结论 比如:合格/不合格"), RESULT_C("resultC", "结论 比如:合格/不合格"), RESULT_MAG("resultMag", "特征幅值结论 比如:合格/不合格"), - RESULT_DUR("resultDur", "持续时间结论 比如:合格/不合格"); + RESULT_DUR("resultDur", "持续时间结论 比如:合格/不合格"), + + // ===== 以下为"指标锚点"新型模板(云南检定报告)新增 key,见 docs/superpowers/specs/2026-07-02-item-anchor-report-template-design.md §5 ===== + STANDARD_V("standardV", "P表:基波电压设置值"), + STANDARD_I("standardI", "P表:基波电流设置值"), + STANDARD_U_ANGLE("standardUAngle", "P表:电压相角设置值"), + STANDARD_I_ANGLE("standardIAngle", "P表:电流相角设置值"), + ACTIVE_POWER("activePower", "P表:有功功率实测值(三相之和)"), + REACTIVE_POWER("reactivePower", "P表:无功功率实测值(数据侧暂无,留空)"), + APPARENT_POWER("apparentPower", "P表:视在功率实测值(数据侧暂无,留空)"), + POWER_FACTOR("powerFactor", "P表:功率因数实测值(数据侧暂无,留空)"), + POS_SEQ("posSeq", "不平衡表:基波正序(数据侧暂无,留空)"), + NEG_SEQ("negSeq", "不平衡表:基波负序(数据侧暂无,留空)"), + ZERO_SEQ("zeroSeq", "不平衡表:基波零序(数据侧暂无,留空)"); private String key; diff --git a/detection/src/main/java/com/njcn/gather/report/service/impl/PqReportServiceImpl.java b/detection/src/main/java/com/njcn/gather/report/service/impl/PqReportServiceImpl.java index b47ea913..74b8baca 100644 --- a/detection/src/main/java/com/njcn/gather/report/service/impl/PqReportServiceImpl.java +++ b/detection/src/main/java/com/njcn/gather/report/service/impl/PqReportServiceImpl.java @@ -52,6 +52,7 @@ import com.njcn.gather.plan.service.IAdPlanTestConfigService; import com.njcn.gather.pojo.enums.DetectionResponseEnum; import com.njcn.gather.report.mapper.PqReportMapper; import com.njcn.gather.report.pojo.DevReportParam; +import com.njcn.gather.report.pojo.constant.ItemAnchorConstant; import com.njcn.gather.report.pojo.constant.PowerConstant; import com.njcn.gather.report.pojo.enums.*; import com.njcn.gather.report.pojo.param.ReportParam; @@ -60,6 +61,8 @@ import com.njcn.gather.report.pojo.result.ContrastTestResult; import com.njcn.gather.report.pojo.result.SingleTestResult; import com.njcn.gather.report.pojo.vo.PqReportVO; import com.njcn.gather.report.service.IPqReportService; +import com.njcn.gather.report.util.ItemAnchorAssembler; +import com.njcn.gather.report.util.ItemAnchorSectionUtil; import com.njcn.gather.result.service.IResultService; import com.njcn.gather.script.pojo.vo.PqScriptDtlDataVO; import com.njcn.gather.script.service.IPqScriptDtlsService; @@ -117,6 +120,7 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.List; @@ -144,6 +148,7 @@ public class PqReportServiceImpl extends ServiceImpl i * 的真实业务取值撞车。 */ private static final String RESULT_MAP_NO_DATA_FLAG = "__internal_no_data__"; + private static final String[] CHINESE_DIGITS = {"O", "一", "二", "三", "四", "五", "六", "七", "八", "九"}; // @Value("${report.template:D:\\template}") // private String templatePath; @@ -926,7 +931,15 @@ public class PqReportServiceImpl extends ServiceImpl i // 获取数据模版页内容,根据脚本动态组装数据页内容 MainDocumentPart detailDocumentPart = detailModelDocument.getMainDocumentPart(); - dealDataModelScatteredByBookmark(baseDocumentPart, detailDocumentPart, devReportParam, pqDevVO); + // 新旧模板分流:base 含白名单内 item_ 前缀书签 → 指标锚点新型模板(云南检定报告类),走独立旁路; + // 否则走原路径,行为与改动前完全一致。设计文档 specs/2026-07-02-item-anchor-report-template-design.md §4.1 + // 书签全文档扫描只做一次,分流判断与新路径共用;老路径方法签名不动,内部自扫 + List baseBookmarks = BookmarkUtil.findAllBookmarks(baseDocumentPart); + if (hasItemAnchor(baseBookmarks)) { + dealDataModelByItemAnchor(baseDocumentPart, detailDocumentPart, devReportParam, pqDevVO, baseBookmarks); + } else { + dealDataModelScatteredByBookmark(baseDocumentPart, detailDocumentPart, devReportParam, pqDevVO); + } // 保存新的文档 String dirPath = pathConfig.getReportPath().concat(File.separator).concat(FilePathSanitizer.toSafeFileName(devType.getName())); @@ -1229,6 +1242,122 @@ public class PqReportServiceImpl extends ServiceImpl i } + /** + * 判断 base 文档是否为"指标锚点"新型模板(含任一白名单内 item_ 前缀书签)。 + * 白名单校验在 resolveItemScriptCode 内:老模板中手工遗留的同前缀书签(如 item_1) + * 不会触发新路径,避免整份报告错路由导致 DATA_LINE / TEST_RESULT_* 全不填。 + * + * @param bookmarks base 文档书签集合(调用方已扫,避免全文档重复扫描) + */ + private boolean hasItemAnchor(List bookmarks) { + for (BookmarkUtil.BookmarkInfo info : bookmarks) { + if (ItemAnchorConstant.resolveItemScriptCode(info.bookmark.getName()) != null) { + return true; + } + } + return false; + } + + /** + * "指标锚点"新型模板装配(云南检定报告类)。 + * 流程:逐 item_* 锚点 → detail 池取 H5 模板表 → 按回路构造成品表插入锚点; + * 无数据/无模板的指标记入隐藏集合 → 区段删除 → 第 7 节编号重排 → 残留书签清理。 + * 设计文档:docs/superpowers/specs/2026-07-02-item-anchor-report-template-design.md §4.2 + */ + private void dealDataModelByItemAnchor(MainDocumentPart baseDocumentPart, MainDocumentPart detailDocumentPart, + DevReportParam devReportParam, PqDevVO pqDevVO, + List bookmarks) { + ObjectFactory factory = new ObjectFactory(); + // 检测脚本明细按 scriptCode 分组 + List pqScriptDtlsList = pqScriptDtlsService.getScriptDtlsDataList(devReportParam.getScriptId()); + Map> scriptMap = pqScriptDtlsList.stream() + .collect(Collectors.groupingBy(PqScriptDtlDataVO::getScriptCode, LinkedHashMap::new, Collectors.toList())); + // detail 模板池按 H5 分组 + List headingContents = Docx4jUtil.extractHeading5Contents(detailDocumentPart.getContent(), detailDocumentPart); + Map> contentMap = headingContents.stream() + .collect(Collectors.groupingBy(Docx4jUtil.HeadingContent::getHeadingText, Collectors.toList())); + int devChns = pqDevVO.getDevChns() == null ? 1 : pqDevVO.getDevChns(); + Set hiddenCodes = new LinkedHashSet<>(); + for (BookmarkUtil.BookmarkInfo info : bookmarks) { + String scriptCode = ItemAnchorConstant.resolveItemScriptCode(info.bookmark.getName()); + if (scriptCode == null) { + // 新路径按设计忽略老锚点(互斥分流,spec §4.1);发现 BookmarkEnum 残留提示模板可能混用 + if (Objects.nonNull(BookmarkEnum.getByKey(info.bookmark.getName()))) { + log.warn("指标锚点模板中发现老模板锚点书签 {},新路径不处理该书签", info.bookmark.getName()); + } + continue; + } + List itemDtls = scriptMap.get(scriptCode); + List tpl = contentMap.get(scriptCode); + if (CollUtil.isEmpty(itemDtls) || CollUtil.isEmpty(tpl)) { + log.warn("指标锚点 {} 无脚本数据或无模板分组,整节隐藏", info.bookmark.getName()); + hiddenCodes.add(scriptCode); + continue; + } + List tableKeys = ItemAnchorAssembler.extractTableKeys(tpl); + // 有脚本明细且模板分组在,keys 仍取不到属模板错误 → 显性中断,不得静默走"未测→整节隐藏" + ItemAnchorAssembler.requireTableKeys(scriptCode, tableKeys); + List todoInsertList = new ArrayList<>(); + for (int line = 0; line < devChns; line++) { + List results = new ArrayList<>(); + if (PowerConstant.TIME.contains(scriptCode)) { + // 谐波类:一个 scriptIndex 一张表(与老路径语义一致) + Map> scriptIndexMap = itemDtls.stream() + .collect(Collectors.groupingBy(PqScriptDtlDataVO::getScriptIndex, TreeMap::new, Collectors.toList())); + for (List indexItem : scriptIndexMap.values()) { + SingleTestResult single = resultService.getFinalContent(indexItem, devReportParam.getPlanCode(), pqDevVO.getId(), line + 1, tableKeys); + if (Objects.nonNull(single)) { + results.add(single); + } + } + } else { + SingleTestResult single = resultService.getFinalContent(itemDtls, devReportParam.getPlanCode(), pqDevVO.getId(), line + 1, tableKeys); + if (Objects.nonNull(single)) { + results.add(single); + } + } + List lineTables = ItemAnchorAssembler.buildTables(scriptCode, results, tpl.get(0), tableKeys, factory); + if (lineTables.isEmpty()) { + // 该回路无数据,跳过;是否整节隐藏由所有回路汇总后判断 + continue; + } + // 回路标题:单回路省略;多回路"测量回路{n}"从 1 起(spec §4.2) + if (devChns > 1) { + todoInsertList.add(buildItemLineTitle(contentMap, line, factory)); + } + todoInsertList.addAll(lineTables); + } + if (todoInsertList.isEmpty()) { + log.warn("指标 {} 所有回路均无检测数据,整节隐藏", scriptCode); + hiddenCodes.add(scriptCode); + continue; + } + BookmarkUtil.insertElement(info, todoInsertList); + // 已填充节不再需要 item/sect 标记:空锚点段直接删除,避免表格前残留空行; + // 若未来模板把锚点放进非空段,则只清书签保留段落正文。 + if (!ItemAnchorSectionUtil.removeEmptyItemAnchorParagraph(info)) { + BookmarkUtil.removeBookmark(info); + } + } + ItemAnchorSectionUtil.removeSectRanges(baseDocumentPart, hiddenCodes); + ItemAnchorSectionUtil.renumberChapter7(baseDocumentPart); + ItemAnchorSectionUtil.stripItemAnchorBookmarks(baseDocumentPart); + } + + /** + * 新型模板的回路标题(仅多回路时被调用):"测量回路{n}",n 从 1 起。 + * 与老路径 getLineTitle 的差异(有意):不带 "n." 前缀、单回路由调用方直接省略。 + */ + private P buildItemLineTitle(Map> contentMap, int lineIndex, ObjectFactory factory) { + int lineNo = lineIndex + 1; + if (CollUtil.isNotEmpty(contentMap.get(PowerIndexEnum.LINE_TITLE.getKey()))) { + return Docx4jUtil.createTitle(factory, 2, "测量回路" + lineNo, "SimSun", 30, true); + } + P titleParagraph = factory.createP(); + Docx4jUtil.createTitle(factory, titleParagraph, "测量回路" + lineNo, 28, true); + return titleParagraph; + } + /** * 通过提前在模板文档里埋下书签 * 1、目录信息 @@ -2168,14 +2297,17 @@ public class PqReportServiceImpl extends ServiceImpl i baseModelMap.put(BaseReportKeyEnum.SAMPLE_ID.getKey(), StrUtil.isEmpty(pqDevVO.getSampleId()) ? StrPool.TAB : pqDevVO.getSampleId()); // 收样日期 baseModelMap.put(BaseReportKeyEnum.ARRIVED_DATE.getKey(), Objects.isNull(pqDevVO.getArrivedDate()) ? StrPool.TAB : String.valueOf(pqDevVO.getArrivedDate())); + baseModelMap.put(BaseReportKeyEnum.ARRIVED_DATE_C.getKey(), Objects.isNull(pqDevVO.getArrivedDate()) ? StrPool.TAB : formatNumberChineseDate(pqDevVO.getArrivedDate())); // 检测日期 baseModelMap.put(BaseReportKeyEnum.TEST_DATE.getKey(), Objects.isNull(pqDevVO.getCheckEndTime()) ? StrPool.TAB : String.valueOf(pqDevVO.getCheckEndTime()).substring(0, 10)); + baseModelMap.put(BaseReportKeyEnum.TEST_DATE_C.getKey(), Objects.isNull(pqDevVO.getCheckEndTime()) ? StrPool.TAB : formatNumberChineseDate(pqDevVO.getCheckEndTime().toLocalDate())); baseModelMap.put(BaseReportKeyEnum.TEMPERATURE.getKey(), Objects.isNull(pqDevVO.getTemperature()) ? StrPool.TAB : pqDevVO.getTemperature().toString()); baseModelMap.put(BaseReportKeyEnum.HUMIDITY.getKey(), Objects.isNull(pqDevVO.getHumidity()) ? StrPool.TAB : pqDevVO.getHumidity().toString()); baseModelMap.put(BaseReportKeyEnum.YEAR.getKey(), DateUtil.format(new Date(), DatePattern.NORM_YEAR_PATTERN)); baseModelMap.put(BaseReportKeyEnum.MONTH.getKey(), DateUtil.format(new Date(), DatePattern.SIMPLE_MONTH_PATTERN).substring(4)); baseModelMap.put(BaseReportKeyEnum.DAY.getKey(), DateUtil.format(new Date(), DatePattern.PURE_DATE_PATTERN).substring(6)); baseModelMap.put(BaseReportKeyEnum.YEAR_MONTH_DAY.getKey(), DateUtil.format(new Date(), DatePattern.NORM_DATE_PATTERN)); + baseModelMap.put(BaseReportKeyEnum.YEAR_MONTH_DAY_UP.getKey(), formatChineseReportDate(LocalDate.now())); baseModelMap.put(BaseReportKeyEnum.REPORT_DATE.getKey(), DateUtil.format(new Date(), DatePattern.CHINESE_DATE_PATTERN)); return baseModelMap; } @@ -2480,6 +2612,40 @@ public class PqReportServiceImpl extends ServiceImpl i return String.format("%.4f", bp.setScale(i, RoundingMode.HALF_UP).doubleValue()); } + static String formatChineseReportDate(LocalDate date) { + Objects.requireNonNull(date, "date"); + return formatChineseYear(date.getYear()) + "年" + + formatChineseMonthDayNumber(date.getMonthValue()) + "月" + + formatChineseMonthDayNumber(date.getDayOfMonth()) + "日"; + } + + static String formatNumberChineseDate(LocalDate date) { + Objects.requireNonNull(date, "date"); + return date.getYear() + "年" + date.getMonthValue() + "月" + date.getDayOfMonth() + "日"; + } + + private static String formatChineseYear(int year) { + String yearValue = String.valueOf(year); + StringBuilder builder = new StringBuilder(yearValue.length()); + for (int i = 0; i < yearValue.length(); i++) { + builder.append(CHINESE_DIGITS[yearValue.charAt(i) - '0']); + } + return builder.toString(); + } + + private static String formatChineseMonthDayNumber(int value) { + if (value < 10) { + return CHINESE_DIGITS[value]; + } + if (value == 10) { + return "十"; + } + if (value < 20) { + return "十" + CHINESE_DIGITS[value % 10]; + } + return CHINESE_DIGITS[value / 10] + "十" + (value % 10 == 0 ? "" : CHINESE_DIGITS[value % 10]); + } + private void ensureDirectoryExists(String directoryPath) { File directory = new File(directoryPath); diff --git a/detection/src/main/java/com/njcn/gather/report/util/ItemAnchorAssembler.java b/detection/src/main/java/com/njcn/gather/report/util/ItemAnchorAssembler.java new file mode 100644 index 00000000..2b652e05 --- /dev/null +++ b/detection/src/main/java/com/njcn/gather/report/util/ItemAnchorAssembler.java @@ -0,0 +1,294 @@ +package com.njcn.gather.report.util; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.njcn.common.pojo.exception.BusinessException; +import com.njcn.gather.report.pojo.enums.ItemReportKeyEnum; +import com.njcn.gather.report.pojo.result.SingleTestResult; +import com.njcn.gather.tools.report.util.Docx4jUtil; +import org.docx4j.wml.BooleanDefaultTrue; +import org.docx4j.wml.HpsMeasure; +import org.docx4j.wml.Jc; +import org.docx4j.wml.JcEnumeration; +import org.docx4j.wml.ObjectFactory; +import org.docx4j.wml.P; +import org.docx4j.wml.PPr; +import org.docx4j.wml.R; +import org.docx4j.wml.RFonts; +import org.docx4j.wml.RPr; +import org.docx4j.wml.Tbl; +import org.docx4j.wml.Tc; +import org.docx4j.wml.TcPr; +import org.docx4j.wml.Text; +import org.docx4j.wml.Tr; +import org.docx4j.wml.TrPr; + +import javax.xml.bind.JAXBElement; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * "指标锚点"模板的表格装配器:模板表(末行为 key 行)+ SingleTestResult → 成品表列表。 + * 只服务新路径,不被老路径引用;对 Docx4jUtil 只调用不修改。 + * 设计文档:docs/superpowers/specs/2026-07-02-item-anchor-report-template-design.md §4.2 / §6 + * + * @author hongawen + */ +public final class ItemAnchorAssembler { + + private ItemAnchorAssembler() { + } + + /** + * 构造成品表。results 每个有效元素产出一张表(谐波类调用方按 scriptIndex 拆分后传入)。 + * + * @param scriptCode 指标编码(P 走有功功率特例映射) + * @param results 检测结果列表,null/空 detail 的元素跳过 + * @param template detail 模板池中该指标的 H5 分组(取其中第一张表为模板) + * @param tableKeys key 行的占位符列表(调用方经 Docx4jUtil.getTableFillKeys 读出) + * @param factory docx4j 工厂 + * @return 待插入锚点的表格元素列表(JAXBElement<Tbl>) + */ + @SuppressWarnings("unchecked") + public static List buildTables(String scriptCode, List results, + Docx4jUtil.HeadingContent template, List tableKeys, + ObjectFactory factory) { + List out = new ArrayList<>(); + JAXBElement templateTbl = findFirstTable(template); + if (templateTbl == null || CollUtil.isEmpty(results) || CollUtil.isEmpty(tableKeys)) { + return out; + } + for (SingleTestResult result : results) { + List> rows = flattenRows(result, scriptCode); + if (rows.isEmpty()) { + continue; + } + JAXBElement copied; + try { + copied = Docx4jUtil.deepCopyTbl(templateTbl); + } catch (Exception e) { + throw new RuntimeException("指标锚点模板表深拷贝失败: " + scriptCode, e); + } + Tbl tbl = copied.getValue(); + List trs = tbl.getContent(); + // 模板规范约定:最后一行是 key 行(不走 judgeTableCross,新路径自带定位依据); + // 从尾部找最后一个真正的 Tr——Word 重存可能把 bookmarkStart/End 漏成 w:tbl 直接子节点(本仓 _GoBack 先例) + Tr keyRow = findLastRow(trs); + if (keyRow == null) { + throw new BusinessException("指标 " + scriptCode + " 的模板表无有效行,请检查 detail 模板该分组表格"); + } + TrPr trPr = keyRow.getTrPr(); + List tcPrList = new ArrayList<>(); + RPr templateRPr = null; + for (Object cellObj : keyRow.getContent()) { + if (cellObj instanceof JAXBElement) { + Tc cell = ((JAXBElement) cellObj).getValue(); + TcPr tcPr = cell.getTcPr() != null ? cell.getTcPr() : factory.createTcPr(); + // 注意:不覆写 tcW(老 fill 的 5000/n 均分会破坏客户模板列宽),沿用模板原宽 + tcPrList.add(tcPr); + if (templateRPr == null && !cell.getContent().isEmpty() && cell.getContent().get(0) instanceof P) { + templateRPr = Docx4jUtil.getTcPrFromParagraph((P) cell.getContent().get(0)); + } + } + } + trs.remove(keyRow); + for (Map row : rows) { + // 缺失 key 一律补空串:"拿不到的先空着"(spec §6 #2) + for (String key : tableKeys) { + row.putIfAbsent(key, ""); + } + Tr newRow = Docx4jUtil.createCustomRow(factory, row, tableKeys, trPr, tcPrList, templateRPr, true); + // Docx4jUtil.createCustomRow 产出的单元格是裸 Tc(未包 JAXBElement), + // 与模板行(真实 docx4j 解析结果 / deepCopy 而来)里统一的 JAXBElement 不一致; + // 这里做一次归一化包装,避免下游按 JAXBElement 读取该行时 ClassCastException(不改 createCustomRow 本身) + List normalized = new ArrayList<>(); + for (Object cellObj : newRow.getContent()) { + if (cellObj instanceof Tc) { + normalized.add(factory.createTrTc((Tc) cellObj)); + } else { + normalized.add(cellObj); + } + } + newRow.getContent().clear(); + newRow.getContent().addAll(normalized); + trs.add(newRow); + } + out.add(copied); + } + // 多表(谐波类多 scriptIndex)背靠背插入时,OOXML 相邻 w:tbl 会被 Word 合并渲染成一张大表; + // 给每张表补"测点{n}"标题段分隔(客户源文档中多测点谐波表本就是带标题的独立表)。单表不加,输出不变 + if (out.size() > 1) { + List withCaptions = new ArrayList<>(out.size() * 2); + for (int i = 0; i < out.size(); i++) { + withCaptions.add(createPointCaption(factory, i + 1)); + withCaptions.add(out.get(i)); + } + return withCaptions; + } + return out; + } + + /** + * 校验模板 key 行占位符已成功取出。调用方仅在"该指标有脚本明细且模板分组存在"时调用: + * 此时 keys 为空属模板错误(key 行缺失或表结构不符合 getTableFillKeys 的识别规则), + * 必须显性中断报告生成,不得静默走"未测→整节隐藏"(否则有数据的节凭空消失,违反红线 2)。 + */ + public static void requireTableKeys(String scriptCode, List tableKeys) { + if (CollUtil.isEmpty(tableKeys)) { + throw new BusinessException("指标 " + scriptCode + " 的模板表 key 行占位符解析为空,请检查 detail 模板该分组的表格(表末行应为占位符 key 行),已中断报告生成"); + } + } + + /** + * 指标锚点模板专用 key 读取:按 spec 直接取 H5 分组第一张表的最后一个真实 Tr。 + * 不复用旧 getTableFillKeys 的横/纵表启发式,避免 HV/HI/HP 标题区末格为数值时被误判。 + */ + public static List extractTableKeys(List tempContent) { + List keys = new ArrayList<>(); + if (CollUtil.isEmpty(tempContent)) { + return keys; + } + JAXBElement table = findFirstTable(tempContent.get(0)); + if (table == null) { + return keys; + } + Tr keyRow = findLastRow(table.getValue().getContent()); + if (keyRow == null) { + return keys; + } + for (Object cellObj : keyRow.getContent()) { + Object value = cellObj instanceof JAXBElement ? ((JAXBElement) cellObj).getValue() : cellObj; + if (value instanceof Tc) { + keys.add(Docx4jUtil.getTextFromCell((Tc) value)); + } + } + return keys; + } + + /** 从尾部向前找最后一个真正的 Tr(含 JAXBElement 解包);无 Tr 返回 null */ + private static Tr findLastRow(List content) { + for (int i = content.size() - 1; i >= 0; i--) { + Object v = content.get(i) instanceof JAXBElement + ? ((JAXBElement) content.get(i)).getValue() : content.get(i); + if (v instanceof Tr) { + return (Tr) v; + } + } + return null; + } + + /** "测点{n}"表标题段:仿宋 9 号加粗居中,与客户源文档"谐波功率检验结果(测点n)"标题段同款式 */ + private static P createPointCaption(ObjectFactory factory, int pointNo) { + P p = factory.createP(); + PPr pPr = factory.createPPr(); + Jc jc = factory.createJc(); + jc.setVal(JcEnumeration.CENTER); + pPr.setJc(jc); + p.setPPr(pPr); + R run = factory.createR(); + RPr rPr = factory.createRPr(); + RFonts fonts = factory.createRFonts(); + fonts.setAscii("仿宋"); + fonts.setHAnsi("仿宋"); + fonts.setEastAsia("仿宋"); + fonts.setCs("仿宋"); + rPr.setRFonts(fonts); + HpsMeasure sz = factory.createHpsMeasure(); + sz.setVal(BigInteger.valueOf(18)); + rPr.setSz(sz); + rPr.setSzCs(sz); + BooleanDefaultTrue bold = factory.createBooleanDefaultTrue(); + rPr.setB(bold); + rPr.setBCs(bold); + run.setRPr(rPr); + Text text = factory.createText(); + text.setValue("测点" + pointNo); + run.getContent().add(text); + p.getContent().add(run); + return p; + } + + /** + * 把 SingleTestResult 的三层嵌套 detail 摊平成行列表,并做新模板专属的行级补充: + * errorScope 注入(HV/HI/HP 的标准限值列)、time 缺失时"测点{n}"兜底(F/IMBV/IMBA)、P 有功特例。 + */ + static List> flattenRows(SingleTestResult result, String scriptCode) { + List> out = new ArrayList<>(); + if (result == null || CollUtil.isEmpty(result.getDetail())) { + return out; + } + int seq = 0; + for (Map.Entry>>>> influenceEntry : result.getDetail().entrySet()) { + if (influenceEntry.getValue() == null) { + continue; + } + for (Map>> byError : influenceEntry.getValue()) { + if (byError == null) { + continue; + } + for (Map.Entry>> errorEntry : byError.entrySet()) { + String errorScope = errorEntry.getKey(); + if (errorEntry.getValue() == null) { + continue; + } + for (Map raw : errorEntry.getValue()) { + seq++; + Map row = new LinkedHashMap<>(raw); + row.putIfAbsent(ItemReportKeyEnum.ERROR_SCOPE.getKey(), errorScope); + if (StrUtil.isBlank(row.get(ItemReportKeyEnum.TIME.getKey()))) { + row.put(ItemReportKeyEnum.TIME.getKey(), "测点" + seq); + } + if ("P".equalsIgnoreCase(scriptCode)) { + applyPowerMapping(row); + } + out.add(row); + } + } + } + } + return out; + } + + /** + * P(有功功率)特例:activePower = 三相实测之和;设置类与无功/视在/功率因数数据侧暂无,留空。 + * spec §6 #2 处置。 + */ + static void applyPowerMapping(Map row) { + BigDecimal sum = BigDecimal.ZERO; + boolean hasValue = false; + for (String key : Arrays.asList(ItemReportKeyEnum.TEST_A.getKey(), + ItemReportKeyEnum.TEST_B.getKey(), ItemReportKeyEnum.TEST_C.getKey())) { + String value = row.get(key); + if (StrUtil.isNotBlank(value)) { + try { + sum = sum.add(new BigDecimal(value.trim())); + hasValue = true; + } catch (NumberFormatException ignored) { + // 非数值(如"--")不参与求和 + } + } + } + String activePower = row.get(ItemReportKeyEnum.ACTIVE_POWER.getKey()); + if (hasValue && (StrUtil.isBlank(activePower) || "/".equals(activePower))) { + row.put(ItemReportKeyEnum.ACTIVE_POWER.getKey(), sum.toPlainString()); + } + } + + @SuppressWarnings("unchecked") + private static JAXBElement findFirstTable(Docx4jUtil.HeadingContent template) { + if (template == null || CollUtil.isEmpty(template.getSubContent())) { + return null; + } + for (Object o : template.getSubContent()) { + if (o instanceof JAXBElement && ((JAXBElement) o).getValue() instanceof Tbl) { + return (JAXBElement) o; + } + } + return null; + } +} diff --git a/detection/src/main/java/com/njcn/gather/report/util/ItemAnchorSectionUtil.java b/detection/src/main/java/com/njcn/gather/report/util/ItemAnchorSectionUtil.java new file mode 100644 index 00000000..e047e300 --- /dev/null +++ b/detection/src/main/java/com/njcn/gather/report/util/ItemAnchorSectionUtil.java @@ -0,0 +1,276 @@ +package com.njcn.gather.report.util; + +import com.njcn.gather.report.pojo.constant.ItemAnchorConstant; +import com.njcn.gather.tools.report.util.BookmarkUtil; +import com.njcn.gather.tools.report.util.Docx4jUtil; +import lombok.extern.slf4j.Slf4j; +import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart; +import org.docx4j.wml.CTBookmark; +import org.docx4j.wml.CTMarkupRange; +import org.docx4j.wml.P; +import org.docx4j.wml.R; +import org.docx4j.wml.Text; + +import javax.xml.bind.JAXBElement; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * "指标锚点"模板的区段隐藏 / 编号重排 / 书签清理工具。 + * 只服务新路径 dealDataModelByItemAnchor,不被老路径引用。 + * 设计文档:docs/superpowers/specs/2026-07-02-item-anchor-report-template-design.md §4.2 + * + * @author hongawen + */ +@Slf4j +public final class ItemAnchorSectionUtil { + + private ItemAnchorSectionUtil() { + } + + /** 三级编号:7.x.y(半角点) */ + private static final Pattern LEVEL3 = Pattern.compile("^7\\.(\\d+)\\.(\\d+)"); + /** 二级编号:7.x(半角点,排除 7.x.y) */ + private static final Pattern LEVEL2 = Pattern.compile("^7\\.(\\d+)(?![\\.\\d])"); + + /** + * 删除已填充 item_* 的空锚点段,避免插入表格前残留一行空段落。 + * 只处理指标锚点新路径的白名单 item_*;段落内存在正文时保留,防止模板内容被误删。 + */ + public static boolean removeEmptyItemAnchorParagraph(BookmarkUtil.BookmarkInfo info) { + if (info == null || info.bookmark == null || info.parentParagraph == null || info.parentContainer == null) { + return false; + } + if (ItemAnchorConstant.resolveItemScriptCode(info.bookmark.getName()) == null) { + return false; + } + String text = Docx4jUtil.getTextFromP(info.parentParagraph); + if (text != null && !text.trim().isEmpty()) { + return false; + } + return info.parentContainer.getContent().remove(info.parentParagraph); + } + + /** + * 删除隐藏指标对应的 sect_{code} 区段(body 块级整段删除)。 + * GRP 折叠规则:GRP_CHILDREN 中某组的子节全部在 hiddenCodes 中时,删父节区段(含组标题与组内公共文字), + * 不再单删子节;父区段定位失败时回退按子节各自删除,避免组和子节一个都删不掉(静默违反红线 2)。 + */ + public static void removeSectRanges(MainDocumentPart part, Set hiddenCodes) { + if (part == null || hiddenCodes == null || hiddenCodes.isEmpty()) { + return; + } + List body = part.getContent(); + // 单遍扫描收集全部书签区段,后续按名 O(1) 取用 + Map allRanges = locateRanges(body); + List ranges = new ArrayList<>(); + Set coveredByGrp = new HashSet<>(); + for (Map.Entry> entry : ItemAnchorConstant.GRP_CHILDREN.entrySet()) { + if (!hiddenCodes.containsAll(entry.getValue())) { + continue; + } + int[] grpRange = allRanges.get((ItemAnchorConstant.SECT_PREFIX + entry.getKey()).toLowerCase()); + if (grpRange != null) { + ranges.add(grpRange); + coveredByGrp.addAll(entry.getValue()); + } else { + log.warn("父区段书签 {}{} 定位失败,回退按子节区段各自删除,请检查模板书签是否完整", + ItemAnchorConstant.SECT_PREFIX, entry.getKey()); + } + } + for (String code : hiddenCodes) { + if (coveredByGrp.contains(code)) { + continue; + } + int[] range = allRanges.get((ItemAnchorConstant.SECT_PREFIX + code).toLowerCase()); + if (range != null) { + ranges.add(range); + } else { + log.warn("区段书签 {}{} 定位失败,该节无法隐藏,请检查模板书签是否完整", + ItemAnchorConstant.SECT_PREFIX, code); + } + } + // 起点降序删除,避免前面的删除让后面的索引失效 + ranges.sort((a, b) -> Integer.compare(b[0], a[0])); + for (int[] range : ranges) { + for (int i = range[1]; i >= range[0]; i--) { + if (i < body.size()) { + body.remove(i); + } + } + } + } + + /** + * 清理全文残留的 item_ / sect_ 书签(start 与配对的 end),其他书签不动。 + * 已填充节的 sect end 会被 BookmarkUtil.removeBookmark 顺带清掉,留下无 end 的 start,本方法兜底清理。 + */ + public static void stripItemAnchorBookmarks(MainDocumentPart part) { + if (part == null) { + return; + } + Set ourIds = new HashSet<>(); + // 第一遍:删 start 并记 id + for (Object block : part.getContent()) { + P p = asP(block); + if (p == null) { + continue; + } + Iterator it = p.getContent().iterator(); + while (it.hasNext()) { + Object v = unwrap(it.next()); + if (v instanceof CTBookmark) { + String name = ((CTBookmark) v).getName(); + if (isOurNamespace(name)) { + ourIds.add(((CTBookmark) v).getId()); + it.remove(); + } + } + } + } + // 第二遍:删配对 end + for (Object block : part.getContent()) { + P p = asP(block); + if (p == null) { + continue; + } + Iterator it = p.getContent().iterator(); + while (it.hasNext()) { + Object v = unwrap(it.next()); + if (v instanceof CTMarkupRange && !(v instanceof CTBookmark) + && ourIds.contains(((CTMarkupRange) v).getId())) { + it.remove(); + } + } + } + } + + /** + * 第 7 节编号重排:区段删除后,把剩余的 7.x / 7.x.y 标题按出现顺序重写为连续编号。 + * 顶级"7.准确度检查:"用全角点,正则天然不匹配,保持不动。 + */ + public static void renumberChapter7(MainDocumentPart part) { + if (part == null) { + return; + } + int l2 = 0; + int l3 = 0; + for (Object block : part.getContent()) { + P p = asP(block); + if (p == null) { + continue; + } + String text = Docx4jUtil.getTextFromP(p); + if (text == null) { + continue; + } + Matcher m3 = LEVEL3.matcher(text); + if (m3.find()) { + l3++; + String newPrefix = "7." + l2 + "." + l3; + if (!m3.group().equals(newPrefix)) { + replacePrefix(p, m3.group().length(), newPrefix); + } + continue; + } + Matcher m2 = LEVEL2.matcher(text); + if (m2.find()) { + l2++; + l3 = 0; + String newPrefix = "7." + l2; + if (!m2.group().equals(newPrefix)) { + replacePrefix(p, m2.group().length(), newPrefix); + } + } + } + } + + /** + * 单遍扫描 body,收集全部段落内书签区段:书签名(小写)→ [start块下标, end块下标]。 + * 同名书签只记首个(与逐名定位"取第一处命中"语义一致);无配对 end 的 start 不产出条目。 + */ + private static Map locateRanges(List body) { + Map out = new HashMap<>(); + Map openNames = new HashMap<>(); + Map openStarts = new HashMap<>(); + for (int i = 0; i < body.size(); i++) { + P p = asP(body.get(i)); + if (p == null) { + continue; + } + for (Object o : p.getContent()) { + Object v = unwrap(o); + // CTBookmark 继承 CTMarkupRange:先判 start,else 分支天然排除 CTBookmark,即 bookmarkEnd + if (v instanceof CTBookmark) { + CTBookmark start = (CTBookmark) v; + if (start.getName() != null && start.getId() != null) { + openNames.put(start.getId(), start.getName().toLowerCase()); + openStarts.put(start.getId(), i); + } + } else if (v instanceof CTMarkupRange) { + BigInteger id = ((CTMarkupRange) v).getId(); + String name = openNames.remove(id); + if (name != null) { + out.putIfAbsent(name, new int[]{openStarts.remove(id), i}); + } + } + } + } + return out; + } + + private static boolean isOurNamespace(String name) { + if (name == null) { + return false; + } + String lower = name.toLowerCase(); + return lower.startsWith(ItemAnchorConstant.ITEM_PREFIX) || lower.startsWith(ItemAnchorConstant.SECT_PREFIX); + } + + private static Object unwrap(Object o) { + return (o instanceof JAXBElement) ? ((JAXBElement) o).getValue() : o; + } + + private static P asP(Object block) { + Object v = unwrap(block); + return (v instanceof P) ? (P) v : null; + } + + /** 跨 run 替换段首前 oldLen 个字符为 newPrefix(编号可能被 Word 拆进多个 text 节点) */ + private static void replacePrefix(P p, int oldLen, String newPrefix) { + List texts = new ArrayList<>(); + collectTexts(p.getContent(), texts); + int remaining = oldLen; + boolean inserted = false; + for (Text t : texts) { + if (remaining <= 0) { + break; + } + String value = t.getValue() == null ? "" : t.getValue(); + int cut = Math.min(remaining, value.length()); + remaining -= cut; + String rest = value.substring(cut); + t.setValue(inserted ? rest : newPrefix + rest); + inserted = true; + } + } + + private static void collectTexts(List content, List out) { + for (Object o : content) { + Object v = unwrap(o); + if (v instanceof R) { + collectTexts(((R) v).getContent(), out); + } else if (v instanceof Text) { + out.add((Text) v); + } + } + } +} diff --git a/detection/src/main/java/com/njcn/gather/result/service/impl/ResultServiceImpl.java b/detection/src/main/java/com/njcn/gather/result/service/impl/ResultServiceImpl.java index 61aad3cf..924e5f92 100644 --- a/detection/src/main/java/com/njcn/gather/result/service/impl/ResultServiceImpl.java +++ b/detection/src/main/java/com/njcn/gather/result/service/impl/ResultServiceImpl.java @@ -64,6 +64,8 @@ import com.njcn.gather.result.pojo.param.ResultParam; import com.njcn.gather.result.pojo.vo.*; import com.njcn.gather.result.service.DataCheckAsyncNotifier; import com.njcn.gather.result.service.IResultService; +import com.njcn.gather.result.util.PowerResultAssembler; +import com.njcn.gather.result.util.UnbalanceResultAssembler; import com.njcn.gather.script.mapper.PqScriptMapper; import com.njcn.gather.script.pojo.param.PqScriptCheckDataParam; import com.njcn.gather.script.pojo.param.PqScriptIssueParam; @@ -1451,6 +1453,39 @@ public class ResultServiceImpl implements IResultService { List indexList = scriptDtlDataVOList.stream().map(PqScriptDtlDataVO::getScriptIndex).distinct().collect(Collectors.toList()); List scriptCheckDataList = pqScriptCheckDataService.listCheckData(scriptId, indexList); List valueTypeList = scriptCheckDataList.stream().map(PqScriptCheckData::getValueType).distinct().collect(Collectors.toList()); + if (PowerIndexEnum.P.getKey().equalsIgnoreCase(scriptCode)) { + SingleNonHarmParam param = new SingleNonHarmParam(planCode, devId, lineNo, valueTypeList, indexList); + List powerResults = simAndDigNonHarmonicService.queryBySortListWithAdTypeCode(param); + List> keyFillMapList = PowerResultAssembler.assembleRows(powerResults, scriptDtlDataVOList); + if (CollUtil.isEmpty(keyFillMapList)) { + log.warn("生成功率类表格数据跳过,结果表数据为空,planCode={}, devId={}, lineNo={}, scriptCode={}, scriptIndexList={}", + planCode, devId, lineNo, scriptCode, indexList); + return; + } + Map>> errorScoperMap = keyFillMapList.stream() + .collect(Collectors.groupingBy(map -> map.get(ItemReportKeyEnum.ERROR_SCOPE.getKey()), LinkedHashMap::new, Collectors.toList())); + List>>> errorList = new ArrayList<>(); + errorList.add(errorScoperMap); + finalContent.put(affectName, errorList); + return; + } + if (PowerIndexEnum.IMBV.getKey().equalsIgnoreCase(scriptCode) + || PowerIndexEnum.IMBA.getKey().equalsIgnoreCase(scriptCode)) { + SingleNonHarmParam param = new SingleNonHarmParam(planCode, devId, lineNo, valueTypeList, indexList); + List unbalanceResults = simAndDigNonHarmonicService.queryBySortListWithAdTypeCode(param); + List> keyFillMapList = UnbalanceResultAssembler.assembleRows(unbalanceResults); + if (CollUtil.isEmpty(keyFillMapList)) { + log.warn("生成不平衡度类表格数据跳过,结果表数据为空,planCode={}, devId={}, lineNo={}, scriptCode={}, scriptIndexList={}", + planCode, devId, lineNo, scriptCode, indexList); + return; + } + Map>> errorScoperMap = keyFillMapList.stream() + .collect(Collectors.groupingBy(map -> map.get(ItemReportKeyEnum.ERROR_SCOPE.getKey()), LinkedHashMap::new, Collectors.toList())); + List>>> errorList = new ArrayList<>(); + errorList.add(errorScoperMap); + finalContent.put(affectName, errorList); + return; + } // 稍作区分,暂态的脚本同一个scriptIndex可以有两个指标,其余脚本每个checkData的valueType对应一个指标结果 if (PowerConstant.VOLTAGE.equalsIgnoreCase(scriptCode)) { // 暂态的valueType通常只有2个,一个特征幅值,一个持续时间 diff --git a/detection/src/main/java/com/njcn/gather/result/util/PowerResultAssembler.java b/detection/src/main/java/com/njcn/gather/result/util/PowerResultAssembler.java new file mode 100644 index 00000000..93c2edd9 --- /dev/null +++ b/detection/src/main/java/com/njcn/gather/result/util/PowerResultAssembler.java @@ -0,0 +1,342 @@ +package com.njcn.gather.result.util; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.njcn.gather.detection.pojo.vo.DetectionData; +import com.njcn.gather.report.pojo.enums.ItemReportKeyEnum; +import com.njcn.gather.script.pojo.po.PqScriptDtls; +import com.njcn.gather.storage.pojo.po.SimAndDigNonHarmonicResult; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * 功率 P 报告结果行装配:同一 sort 下可能同时存在总有功、总无功、总视在等结果。 + */ +public final class PowerResultAssembler { + + private PowerResultAssembler() { + } + + public static List> assembleRows(List results) { + return assembleRows(results, Collections.emptyList()); + } + + public static List> assembleRows(List results, + List scriptDetails) { + if (CollUtil.isEmpty(results)) { + return new ArrayList<>(); + } + Map> scriptDetailsBySort = groupScriptDetailsBySort(scriptDetails); + Map> bySort = results.stream() + .filter(result -> result.getSort() != null) + .collect(Collectors.groupingBy(SimAndDigNonHarmonicResult::getSort, TreeMap::new, Collectors.toList())); + List> rows = new ArrayList<>(); + for (Map.Entry> sortEntry : bySort.entrySet()) { + List sameSortResults = sortEntry.getValue(); + Map row = defaultPowerRow(); + DetectionData primary = null; + boolean hasPowerValue = false; + for (SimAndDigNonHarmonicResult result : sameSortResults) { + String code = result.getAdType(); + if (equalsCode(code, "TotW")) { + DetectionData data = parse(result.getTValue()); + hasPowerValue |= putData(row, ItemReportKeyEnum.ACTIVE_POWER.getKey(), data); + primary = firstPresent(data, primary); + } else if (equalsCode(code, "TotVAr")) { + hasPowerValue |= putData(row, ItemReportKeyEnum.REACTIVE_POWER.getKey(), parse(result.getTValue())); + } else if (equalsCode(code, "TotVA")) { + hasPowerValue |= putData(row, ItemReportKeyEnum.APPARENT_POWER.getKey(), parse(result.getTValue())); + } else if (equalsCode(code, "TotPF")) { + DetectionData data = parse(result.getTValue()); + hasPowerValue |= putData(row, ItemReportKeyEnum.POWER_FACTOR.getKey(), data); + } else if (equalsCode(code, "PF")) { + if (isBlankPowerValue(row, ItemReportKeyEnum.POWER_FACTOR.getKey())) { + DetectionData data = firstPresent(parse(result.getTValue()), phaseAverage(result)); + hasPowerValue |= putData(row, ItemReportKeyEnum.POWER_FACTOR.getKey(), data); + } + } else if (equalsCode(code, "W")) { + DetectionData[] phases = phases(result); + if (isBlankPowerValue(row, ItemReportKeyEnum.ACTIVE_POWER.getKey())) { + hasPowerValue |= putSum(row, ItemReportKeyEnum.ACTIVE_POWER.getKey(), phases); + } + putPhaseDetails(row, phases); + primary = preferPrimary(primary, firstPresent(phases)); + } else if (equalsCode(code, "VARW")) { + if (isBlankPowerValue(row, ItemReportKeyEnum.REACTIVE_POWER.getKey())) { + hasPowerValue |= putSum(row, ItemReportKeyEnum.REACTIVE_POWER.getKey(), phases(result)); + } + } else if (equalsCode(code, "VAW")) { + if (isBlankPowerValue(row, ItemReportKeyEnum.APPARENT_POWER.getKey())) { + hasPowerValue |= putSum(row, ItemReportKeyEnum.APPARENT_POWER.getKey(), phases(result)); + } + } + } + if (primary != null) { + putPrimary(row, primary); + } + if (hasPowerValue) { + putStandardSourceValues(row, sortEntry.getKey(), scriptDetailsBySort); + rows.add(row); + } + } + return rows; + } + + private static Map defaultPowerRow() { + Map row = new LinkedHashMap<>(); + List defaultKeys = Arrays.asList( + ItemReportKeyEnum.SCRIPT_DETAIL, + ItemReportKeyEnum.STANDARD, + ItemReportKeyEnum.STANDARD_A, + ItemReportKeyEnum.STANDARD_B, + ItemReportKeyEnum.STANDARD_C, + ItemReportKeyEnum.STANDARD_V, + ItemReportKeyEnum.STANDARD_I, + ItemReportKeyEnum.STANDARD_U_ANGLE, + ItemReportKeyEnum.STANDARD_I_ANGLE, + ItemReportKeyEnum.TEST, + ItemReportKeyEnum.TEST_A, + ItemReportKeyEnum.TEST_B, + ItemReportKeyEnum.TEST_C, + ItemReportKeyEnum.ERROR, + ItemReportKeyEnum.ERROR_A, + ItemReportKeyEnum.ERROR_B, + ItemReportKeyEnum.ERROR_C, + ItemReportKeyEnum.ERROR_SCOPE, + ItemReportKeyEnum.RESULT, + ItemReportKeyEnum.RESULT_A, + ItemReportKeyEnum.RESULT_B, + ItemReportKeyEnum.RESULT_C, + ItemReportKeyEnum.ACTIVE_POWER, + ItemReportKeyEnum.REACTIVE_POWER, + ItemReportKeyEnum.APPARENT_POWER, + ItemReportKeyEnum.POWER_FACTOR + ); + for (ItemReportKeyEnum key : defaultKeys) { + row.put(key.getKey(), "/"); + } + return row; + } + + private static Map> groupScriptDetailsBySort(List scriptDetails) { + Map> bySort = new TreeMap<>(); + if (CollUtil.isEmpty(scriptDetails)) { + return bySort; + } + for (PqScriptDtls detail : scriptDetails) { + if (detail == null || detail.getScriptIndex() == null) { + continue; + } + bySort.computeIfAbsent(detail.getScriptIndex(), key -> new ArrayList<>()).add(detail); + } + return bySort; + } + + private static void putStandardSourceValues(Map row, Integer sort, + Map> scriptDetailsBySort) { + List sameSortDetails = scriptDetailsBySort.get(sort); + if (CollUtil.isEmpty(sameSortDetails)) { + return; + } + PqScriptDtls voltage = pickPhaseDetail(sameSortDetails, "VOL"); + PqScriptDtls current = pickPhaseDetail(sameSortDetails, "CUR"); + putDouble(row, ItemReportKeyEnum.STANDARD_V.getKey(), voltage == null ? null : voltage.getValue()); + putDouble(row, ItemReportKeyEnum.STANDARD_I.getKey(), current == null ? null : current.getValue()); + putDouble(row, ItemReportKeyEnum.STANDARD_U_ANGLE.getKey(), voltage == null ? null : voltage.getAngle()); + putDouble(row, ItemReportKeyEnum.STANDARD_I_ANGLE.getKey(), current == null ? null : current.getAngle()); + } + + private static PqScriptDtls pickPhaseDetail(List details, String valueType) { + for (String phase : Arrays.asList("A", "B", "C")) { + PqScriptDtls detail = firstMatchingDetail(details, valueType, phase); + if (detail != null) { + return detail; + } + } + return firstMatchingDetail(details, valueType, null); + } + + private static PqScriptDtls firstMatchingDetail(List details, String valueType, String phase) { + for (PqScriptDtls detail : details) { + if (!equalsCode(detail.getValueType(), valueType)) { + continue; + } + if (phase != null && !equalsCode(detail.getPhase(), phase)) { + continue; + } + if (detail.getValue() != null || detail.getAngle() != null) { + return detail; + } + } + return null; + } + + private static void putDouble(Map row, String key, Double value) { + if (value != null) { + row.put(key, format(value)); + } + } + + private static boolean putData(Map row, String key, DetectionData data) { + if (data == null || data.getData() == null) { + return false; + } + row.put(key, format(data.getData())); + return true; + } + + private static boolean putSum(Map row, String key, DetectionData[] phases) { + BigDecimal sum = BigDecimal.ZERO; + boolean hasValue = false; + for (DetectionData phase : phases) { + if (phase != null && phase.getData() != null) { + sum = sum.add(BigDecimal.valueOf(phase.getData())); + hasValue = true; + } + } + if (hasValue) { + row.put(key, format(sum)); + } + return hasValue; + } + + private static boolean isBlankPowerValue(Map row, String key) { + String value = row.get(key); + return StrUtil.isBlank(value) || "/".equals(value); + } + + private static void putPrimary(Map row, DetectionData primary) { + row.put(ItemReportKeyEnum.STANDARD.getKey(), valueOrSlash(primary.getResultData())); + row.put(ItemReportKeyEnum.TEST.getKey(), valueOrSlash(primary.getData())); + row.put(ItemReportKeyEnum.ERROR.getKey(), primary.getErrorData() == null ? "/" : format(primary.getErrorData())); + row.put(ItemReportKeyEnum.RESULT.getKey(), resultText(primary.getIsData())); + String errorScope = dealErrorScope(primary.getRadius()); + if (StrUtil.isNotBlank(primary.getUnit()) && !"/".equals(errorScope)) { + errorScope = errorScope.concat(primary.getUnit()); + } + row.put(ItemReportKeyEnum.ERROR_SCOPE.getKey(), errorScope); + } + + private static void putPhaseDetails(Map row, DetectionData[] phases) { + List standards = Arrays.asList(ItemReportKeyEnum.STANDARD_A.getKey(), + ItemReportKeyEnum.STANDARD_B.getKey(), ItemReportKeyEnum.STANDARD_C.getKey()); + List tests = Arrays.asList(ItemReportKeyEnum.TEST_A.getKey(), + ItemReportKeyEnum.TEST_B.getKey(), ItemReportKeyEnum.TEST_C.getKey()); + List errors = Arrays.asList(ItemReportKeyEnum.ERROR_A.getKey(), + ItemReportKeyEnum.ERROR_B.getKey(), ItemReportKeyEnum.ERROR_C.getKey()); + List results = Arrays.asList(ItemReportKeyEnum.RESULT_A.getKey(), + ItemReportKeyEnum.RESULT_B.getKey(), ItemReportKeyEnum.RESULT_C.getKey()); + for (int i = 0; i < phases.length; i++) { + DetectionData phase = phases[i]; + row.put(standards.get(i), phase == null ? "/" : valueOrSlash(phase.getResultData())); + row.put(tests.get(i), phase == null ? "/" : valueOrSlash(phase.getData())); + row.put(errors.get(i), phase == null || phase.getErrorData() == null ? "/" : format(phase.getErrorData())); + row.put(results.get(i), phase == null ? "/" : resultText(phase.getIsData())); + } + } + + private static DetectionData phaseAverage(SimAndDigNonHarmonicResult result) { + DetectionData[] phases = phases(result); + BigDecimal sum = BigDecimal.ZERO; + int count = 0; + for (DetectionData phase : phases) { + if (phase != null && phase.getData() != null) { + sum = sum.add(BigDecimal.valueOf(phase.getData())); + count++; + } + } + if (count == 0) { + return null; + } + DetectionData data = new DetectionData(); + data.setData(sum.divide(BigDecimal.valueOf(count), 8, RoundingMode.HALF_UP).doubleValue()); + return data; + } + + private static DetectionData[] phases(SimAndDigNonHarmonicResult result) { + return new DetectionData[]{ + parse(result.getAValue()), + parse(result.getBValue()), + parse(result.getCValue()) + }; + } + + private static DetectionData firstPresent(DetectionData... values) { + if (values == null) { + return null; + } + for (DetectionData value : values) { + if (value != null) { + return value; + } + } + return null; + } + + private static DetectionData preferPrimary(DetectionData current, DetectionData candidate) { + return current != null ? current : candidate; + } + + private static DetectionData parse(String value) { + if (StrUtil.isBlank(value)) { + return null; + } + return JSONUtil.toBean(value, DetectionData.class); + } + + private static boolean equalsCode(String actual, String expected) { + return expected.equalsIgnoreCase(actual); + } + + private static String resultText(Integer isData) { + if (isData == null) { + return "/"; + } + if (isData == 1) { + return "合格"; + } + if (isData == 2) { + return "不合格"; + } + return "/"; + } + + private static String valueOrSlash(Double value) { + return value == null ? "/" : format(value); + } + + private static String format(Double value) { + return value == null ? "/" : format(BigDecimal.valueOf(value)); + } + + private static String format(BigDecimal value) { + return value.setScale(4, RoundingMode.HALF_UP).toPlainString(); + } + + private static String dealErrorScope(String errorScope) { + if (StrUtil.isBlank(errorScope)) { + return "/"; + } + if (errorScope.contains("~")) { + String[] split = errorScope.split("~"); + String begin = split[0]; + if (begin.startsWith("-")) { + begin = begin.substring(1); + } + if (split.length > 1 && split[1].equalsIgnoreCase(begin)) { + return "±" + begin; + } + } + return errorScope; + } +} diff --git a/detection/src/main/java/com/njcn/gather/result/util/UnbalanceResultAssembler.java b/detection/src/main/java/com/njcn/gather/result/util/UnbalanceResultAssembler.java new file mode 100644 index 00000000..c2a8a180 --- /dev/null +++ b/detection/src/main/java/com/njcn/gather/result/util/UnbalanceResultAssembler.java @@ -0,0 +1,166 @@ +package com.njcn.gather.result.util; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.njcn.gather.detection.pojo.vo.DetectionData; +import com.njcn.gather.report.pojo.enums.ItemReportKeyEnum; +import com.njcn.gather.storage.pojo.po.SimAndDigNonHarmonicResult; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * IMBV/IMBA 报告结果行装配:同一 sort 下的不平衡度 T 值与序分量 ABC 值合并为一行。 + */ +public final class UnbalanceResultAssembler { + + private UnbalanceResultAssembler() { + } + + public static List> assembleRows(List results) { + if (CollUtil.isEmpty(results)) { + return java.util.Collections.emptyList(); + } + Map> bySort = results.stream() + .filter(result -> result.getSort() != null) + .collect(Collectors.groupingBy(SimAndDigNonHarmonicResult::getSort, TreeMap::new, Collectors.toList())); + return bySort.values().stream() + .map(UnbalanceResultAssembler::assembleRow) + .filter(row -> row != null) + .collect(Collectors.toList()); + } + + private static Map assembleRow(List sameSortResults) { + Map row = defaultRow(); + boolean hasData = false; + for (SimAndDigNonHarmonicResult result : sameSortResults) { + String code = result.getAdType(); + if (equalsAny(code, "V_UNBAN", "I_UNBAN")) { + DetectionData data = parse(result.getTValue()); + if (data != null) { + putPrimary(row, data); + hasData = true; + } + } else if (equalsAny(code, "SeqV", "SeqA")) { + hasData |= putSequence(row, result); + } + } + return hasData ? row : null; + } + + private static Map defaultRow() { + Map row = new LinkedHashMap<>(); + for (ItemReportKeyEnum key : Arrays.asList( + ItemReportKeyEnum.TIME, + ItemReportKeyEnum.STANDARD, + ItemReportKeyEnum.TEST, + ItemReportKeyEnum.ERROR, + ItemReportKeyEnum.ERROR_SCOPE, + ItemReportKeyEnum.POS_SEQ, + ItemReportKeyEnum.NEG_SEQ, + ItemReportKeyEnum.ZERO_SEQ, + ItemReportKeyEnum.RESULT)) { + row.put(key.getKey(), "/"); + } + return row; + } + + private static void putPrimary(Map row, DetectionData data) { + row.put(ItemReportKeyEnum.STANDARD.getKey(), valueOrSlash(data.getResultData())); + row.put(ItemReportKeyEnum.TEST.getKey(), valueOrSlash(data.getData())); + row.put(ItemReportKeyEnum.ERROR.getKey(), data.getErrorData() == null ? "/" : format(data.getErrorData())); + String errorScope = dealErrorScope(data.getRadius()); + if (StrUtil.isNotBlank(data.getUnit()) && !"/".equals(errorScope)) { + errorScope = errorScope.concat(data.getUnit()); + } + row.put(ItemReportKeyEnum.ERROR_SCOPE.getKey(), errorScope); + row.put(ItemReportKeyEnum.RESULT.getKey(), resultText(data.getIsData())); + } + + private static boolean putSequence(Map row, SimAndDigNonHarmonicResult result) { + boolean hasData = false; + hasData |= putData(row, ItemReportKeyEnum.POS_SEQ.getKey(), parse(result.getAValue())); + hasData |= putData(row, ItemReportKeyEnum.NEG_SEQ.getKey(), parse(result.getBValue())); + hasData |= putData(row, ItemReportKeyEnum.ZERO_SEQ.getKey(), parse(result.getCValue())); + return hasData; + } + + private static boolean putData(Map row, String key, DetectionData data) { + if (data == null || data.getData() == null) { + return false; + } + row.put(key, format(data.getData())); + return true; + } + + private static DetectionData parse(String value) { + if (StrUtil.isBlank(value)) { + return null; + } + return JSONUtil.toBean(value, DetectionData.class); + } + + private static String resultText(Integer isData) { + if (isData == null) { + return "/"; + } + if (isData == 1) { + return "合格"; + } + if (isData == 2) { + return "不合格"; + } + if (isData == 4) { + return "无法比较"; + } + return "/"; + } + + private static String valueOrSlash(Double value) { + return value == null ? "/" : format(value); + } + + private static String format(Double value) { + return value == null ? "/" : format(BigDecimal.valueOf(value)); + } + + private static String format(BigDecimal value) { + return value.setScale(4, RoundingMode.HALF_UP).toPlainString(); + } + + private static String dealErrorScope(String errorScope) { + if (StrUtil.isBlank(errorScope)) { + return "/"; + } + if (errorScope.contains("~")) { + String[] split = errorScope.split("~"); + String begin = split[0]; + if (begin.startsWith("-")) { + begin = begin.substring(1); + } + if (split.length > 1 && split[1].equalsIgnoreCase(begin)) { + return "±" + begin; + } + } + return errorScope; + } + + private static boolean equalsAny(String actual, String... expectedValues) { + if (actual == null) { + return false; + } + for (String expected : expectedValues) { + if (expected.equalsIgnoreCase(actual)) { + return true; + } + } + return false; + } +} diff --git a/detection/src/test/java/com/njcn/gather/report/pojo/constant/ItemAnchorConstantTest.java b/detection/src/test/java/com/njcn/gather/report/pojo/constant/ItemAnchorConstantTest.java new file mode 100644 index 00000000..d3a63885 --- /dev/null +++ b/detection/src/test/java/com/njcn/gather/report/pojo/constant/ItemAnchorConstantTest.java @@ -0,0 +1,45 @@ +package com.njcn.gather.report.pojo.constant; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ItemAnchorConstantTest { + + @Test + public void resolveItemScriptCode_normal() { + assertEquals("V", ItemAnchorConstant.resolveItemScriptCode("item_V")); + assertEquals("FREQ", ItemAnchorConstant.resolveItemScriptCode("ITEM_FREQ")); // 前缀大小写不敏感 + } + + @Test + public void resolveItemScriptCode_nonAnchor() { + assertNull(ItemAnchorConstant.resolveItemScriptCode("_GoBack")); + assertNull(ItemAnchorConstant.resolveItemScriptCode("data_line")); + assertNull(ItemAnchorConstant.resolveItemScriptCode("sect_V")); + assertNull(ItemAnchorConstant.resolveItemScriptCode(null)); + assertNull(ItemAnchorConstant.resolveItemScriptCode("item_")); // 空 code 视为非法 + } + + /** F3:白名单外的 item_ 书签(如作者手工遗留的 item_1)不得解析成功,否则老模板会整体误入新路径 */ + @Test + public void resolveItemScriptCode_unknownCode_rejected() { + assertNull(ItemAnchorConstant.resolveItemScriptCode("item_1")); + assertNull(ItemAnchorConstant.resolveItemScriptCode("item_XYZ")); + assertNull(ItemAnchorConstant.resolveItemScriptCode("item_HSV")); // TIME 里有但云南模板白名单没有 + } + + /** F3:白名单命中时大小写归一为规范大写(scriptMap / 模板 H5 分组 key 均为大写) */ + @Test + public void resolveItemScriptCode_caseNormalized() { + assertEquals("HV", ItemAnchorConstant.resolveItemScriptCode("item_hv")); + assertEquals("IMBV", ItemAnchorConstant.resolveItemScriptCode("Item_imbv")); + } + + @Test + public void grpChildren_mapping() { + assertEquals(java.util.Arrays.asList("V", "I", "P", "ANGLE"), ItemAnchorConstant.GRP_CHILDREN.get("GRP_FUND")); + assertEquals(java.util.Arrays.asList("IMBV", "IMBA"), ItemAnchorConstant.GRP_CHILDREN.get("GRP_UNB")); + assertEquals(java.util.Arrays.asList("HV", "HI", "HP"), ItemAnchorConstant.GRP_CHILDREN.get("GRP_HARM")); + } +} diff --git a/detection/src/test/java/com/njcn/gather/report/service/impl/PqReportServiceImplTest.java b/detection/src/test/java/com/njcn/gather/report/service/impl/PqReportServiceImplTest.java new file mode 100644 index 00000000..a55b9d81 --- /dev/null +++ b/detection/src/test/java/com/njcn/gather/report/service/impl/PqReportServiceImplTest.java @@ -0,0 +1,25 @@ +package com.njcn.gather.report.service.impl; + +import org.junit.Test; + +import java.time.LocalDate; + +import static org.junit.Assert.assertEquals; + +public class PqReportServiceImplTest { + + @Test + public void formatChineseReportDateUsesChineseNumerals() { + assertEquals("二O二六年一月七日", PqReportServiceImpl.formatChineseReportDate(LocalDate.of(2026, 1, 7))); + } + + @Test + public void formatChineseReportDateHandlesTwoDigitMonthAndDay() { + assertEquals("二O二六年十二月三十一日", PqReportServiceImpl.formatChineseReportDate(LocalDate.of(2026, 12, 31))); + } + + @Test + public void formatNumberChineseDateUsesChineseDateSeparators() { + assertEquals("2021年1月1日", PqReportServiceImpl.formatNumberChineseDate(LocalDate.of(2021, 1, 1))); + } +} diff --git a/detection/src/test/java/com/njcn/gather/report/util/Docx4jUtilTextHelper.java b/detection/src/test/java/com/njcn/gather/report/util/Docx4jUtilTextHelper.java new file mode 100644 index 00000000..3f3b52b0 --- /dev/null +++ b/detection/src/test/java/com/njcn/gather/report/util/Docx4jUtilTextHelper.java @@ -0,0 +1,32 @@ +package com.njcn.gather.report.util; + +import com.njcn.gather.tools.report.util.BookmarkUtil; +import com.njcn.gather.tools.report.util.Docx4jUtil; +import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart; +import org.docx4j.wml.P; + +import javax.xml.bind.JAXBElement; +import java.util.ArrayList; +import java.util.List; + +/** 单测辅助:读块文本 / 收集书签名 */ +final class Docx4jUtilTextHelper { + + private Docx4jUtilTextHelper() { + } + + static String textOf(MainDocumentPart mdp, int blockIndex) { + Object o = mdp.getContent().get(blockIndex); + Object v = (o instanceof JAXBElement) ? ((JAXBElement) o).getValue() : o; + return (v instanceof P) ? Docx4jUtil.getTextFromP((P) v) : ""; + } + + /** 直接复用生产扫描器 BookmarkUtil.findAllBookmarks,保证与生产"可见书签"语义一致(含表格内书签) */ + static List allBookmarkNames(MainDocumentPart mdp) { + List names = new ArrayList<>(); + for (BookmarkUtil.BookmarkInfo info : BookmarkUtil.findAllBookmarks(mdp)) { + names.add(info.bookmark.getName()); + } + return names; + } +} diff --git a/detection/src/test/java/com/njcn/gather/report/util/ItemAnchorAssemblerTest.java b/detection/src/test/java/com/njcn/gather/report/util/ItemAnchorAssemblerTest.java new file mode 100644 index 00000000..1b81813c --- /dev/null +++ b/detection/src/test/java/com/njcn/gather/report/util/ItemAnchorAssemblerTest.java @@ -0,0 +1,320 @@ +package com.njcn.gather.report.util; + +import com.njcn.common.pojo.exception.BusinessException; +import com.njcn.gather.report.pojo.result.SingleTestResult; +import com.njcn.gather.tools.report.util.Docx4jUtil; +import org.docx4j.wml.CTMarkupRange; +import org.docx4j.wml.ObjectFactory; +import org.docx4j.wml.P; +import org.docx4j.wml.R; +import org.docx4j.wml.Tbl; +import org.docx4j.wml.Tc; +import org.docx4j.wml.Text; +import org.docx4j.wml.Tr; +import org.junit.Test; + +import javax.xml.bind.JAXBElement; +import javax.xml.namespace.QName; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ItemAnchorAssemblerTest { + + private static final String W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + + private final ObjectFactory f = new ObjectFactory(); + + private P para(String text) { + P p = f.createP(); + R r = f.createR(); + Text t = f.createText(); + t.setValue(text); + r.getContent().add(t); + p.getContent().add(r); + return p; + } + + private Tc cell(String text) { + Tc tc = f.createTc(); + tc.getContent().add(para(text)); + return tc; + } + + private Tr row(String... cellTexts) { + Tr tr = f.createTr(); + for (String t : cellTexts) { + tr.getContent().add(f.createTrTc(cell(t))); + } + return tr; + } + + /** 模板:1 行表头 + 1 行 key 行(standard/test/error/result) */ + private Docx4jUtil.HeadingContent template(String... keys) { + Tbl tbl = f.createTbl(); + String[] headers = new String[keys.length]; + Arrays.fill(headers, "表头"); + tbl.getContent().add(row(headers)); + tbl.getContent().add(row(keys)); + JAXBElement el = new JAXBElement<>(new QName(W_NS, "tbl"), Tbl.class, tbl); + Docx4jUtil.HeadingContent hc = new Docx4jUtil.HeadingContent(); + hc.setHeadingText("FREQ"); + hc.addSubContent(el); + return hc; + } + + private Docx4jUtil.HeadingContent hvTemplate(String... keys) { + Tbl tbl = f.createTbl(); + tbl.getContent().add(row("谐波电压检验结果", "基波电压(V)", "57.7000")); + tbl.getContent().add(row("总谐波畸变率(%)", "", "", "", "")); + tbl.getContent().add(row("测点", "标准源输出值", "被检仪器测量值", "误差", "标准限值", "检验结论")); + tbl.getContent().add(row("", "", "A", "B", "C", "A", "B", "C", "", "A", "B", "C")); + tbl.getContent().add(row("THD", "", "", "", "", "", "", "", "", "", "", "")); + tbl.getContent().add(row(keys)); + JAXBElement el = new JAXBElement<>(new QName(W_NS, "tbl"), Tbl.class, tbl); + Docx4jUtil.HeadingContent hc = new Docx4jUtil.HeadingContent(); + hc.setHeadingText("HV"); + hc.addSubContent(el); + return hc; + } + + private SingleTestResult resultWithRows(String errorScope, List> rows) { + Map>> byError = new LinkedHashMap<>(); + byError.put(errorScope, rows); + Map>>>> detail = new LinkedHashMap<>(); + detail.put("额定工作条件", Collections.singletonList(byError)); + SingleTestResult r = new SingleTestResult(); + r.setDetail(detail); + return r; + } + + private String cellText(Tbl tbl, int rowIdx, int colIdx) { + Tr tr = (Tr) tbl.getContent().get(rowIdx); + @SuppressWarnings("unchecked") + JAXBElement tc = (JAXBElement) tr.getContent().get(colIdx); + return Docx4jUtil.getTextFromCell(tc.getValue()); + } + + @Test + public void buildTables_fillsRowsAndRemovesKeyRow() { + Docx4jUtil.HeadingContent tpl = template("standard", "test", "error", "result"); + Map row1 = new HashMap<>(); + row1.put("standard", "50.0000"); + row1.put("test", "50.0001"); + row1.put("error", "0.0001"); + row1.put("result", "合格"); + Map row2 = new HashMap<>(row1); + row2.put("standard", "48.0000"); + SingleTestResult r = resultWithRows("0.01", Arrays.asList(row1, row2)); + List out = ItemAnchorAssembler.buildTables("FREQ", Collections.singletonList(r), tpl, + Arrays.asList("standard", "test", "error", "result"), f); + assertEquals(1, out.size()); + @SuppressWarnings("unchecked") + Tbl tbl = ((JAXBElement) out.get(0)).getValue(); + // 表头 + 2 行数据(key 行已删) + assertEquals(3, tbl.getContent().size()); + assertEquals("50.0000", cellText(tbl, 1, 0)); + assertEquals("48.0000", cellText(tbl, 2, 0)); + assertEquals("合格", cellText(tbl, 1, 3)); + // 原模板未被改动(深拷贝) + @SuppressWarnings("unchecked") + Tbl original = ((JAXBElement) tpl.getSubContent().get(0)).getValue(); + assertEquals(2, original.getContent().size()); + } + + @Test + public void buildTables_missingKeysBlank_andErrorScopeInjected() { + Docx4jUtil.HeadingContent tpl = template("time", "standard", "errorScope", "posSeq"); + Map row1 = new HashMap<>(); + row1.put("standard", "0.0000"); // 无 time/posSeq/errorScope + SingleTestResult r = resultWithRows("0.15%", Collections.singletonList(row1)); + List out = ItemAnchorAssembler.buildTables("IMBV", Collections.singletonList(r), tpl, + Arrays.asList("time", "standard", "errorScope", "posSeq"), f); + @SuppressWarnings("unchecked") + Tbl tbl = ((JAXBElement) out.get(0)).getValue(); + assertEquals("测点1", cellText(tbl, 1, 0)); // time 缺失 → 测点{n} 兜底 + assertEquals("0.15%", cellText(tbl, 1, 2)); // errorScope ← 分组 key + assertEquals("", cellText(tbl, 1, 3)); // 数据侧没有的列留空 + } + + @Test + public void buildTables_powerMapping_sumsThreePhases() { + Docx4jUtil.HeadingContent tpl = template("standardV", "activePower", "reactivePower"); + Map row1 = new HashMap<>(); + row1.put("testA", "288.4515"); + row1.put("testB", "288.4515"); + row1.put("testC", "288.4515"); + SingleTestResult r = resultWithRows("1%", Collections.singletonList(row1)); + List out = ItemAnchorAssembler.buildTables("P", Collections.singletonList(r), tpl, + Arrays.asList("standardV", "activePower", "reactivePower"), f); + @SuppressWarnings("unchecked") + Tbl tbl = ((JAXBElement) out.get(0)).getValue(); + assertEquals("", cellText(tbl, 1, 0)); // 设置值数据侧暂无 → 留空 + assertEquals("865.3545", cellText(tbl, 1, 1)); // 有功 = 三相实测之和 + assertEquals("", cellText(tbl, 1, 2)); // 无功暂无 → 留空 + } + + @Test + public void buildTables_powerMapping_keepsExistingActivePower() { + Docx4jUtil.HeadingContent tpl = template("activePower"); + Map row1 = new HashMap<>(); + row1.put("activePower", "1000.0000"); + row1.put("testA", "1.0"); + row1.put("testB", "2.0"); + row1.put("testC", "3.0"); + SingleTestResult r = resultWithRows("1%", Collections.singletonList(row1)); + + List out = ItemAnchorAssembler.buildTables("P", Collections.singletonList(r), tpl, + Collections.singletonList("activePower"), f); + + @SuppressWarnings("unchecked") + Tbl tbl = ((JAXBElement) out.get(0)).getValue(); + assertEquals("1000.0000", cellText(tbl, 1, 0)); + } + + @Test + public void buildTables_multipleResults_multipleTables() { + Docx4jUtil.HeadingContent tpl = template("time", "standard"); + Map row = new HashMap<>(); + row.put("time", "2"); + row.put("standard", "0.5000"); + SingleTestResult r1 = resultWithRows("0.05", Collections.singletonList(row)); + SingleTestResult r2 = resultWithRows("0.05", Collections.singletonList(row)); + SingleTestResult empty = new SingleTestResult(); // detail 为 null → 跳过 + List out = ItemAnchorAssembler.buildTables("HV", Arrays.asList(r1, empty, r2), tpl, + Arrays.asList("time", "standard"), f); + // 空结果跳过 → 2 张表;多表时每张表前有"测点{n}"标题段 → 共 4 个元素 + assertEquals(4, out.size()); + assertEquals(2, countTables(out)); + } + + /** F1:多表(谐波多 scriptIndex)背靠背插入会被 Word 合并渲染成一张表,相邻表之间必须有段落分隔 */ + @Test + public void buildTables_multipleTables_separatedByPointCaption() { + Docx4jUtil.HeadingContent tpl = template("time", "standard"); + Map row = new HashMap<>(); + row.put("time", "2"); + row.put("standard", "0.5000"); + SingleTestResult r1 = resultWithRows("0.05", Collections.singletonList(row)); + SingleTestResult r2 = resultWithRows("0.05", Collections.singletonList(row)); + List out = ItemAnchorAssembler.buildTables("HV", Arrays.asList(r1, r2), tpl, + Arrays.asList("time", "standard"), f); + // 期望序列:测点1标题段、表1、测点2标题段、表2 + assertEquals(4, out.size()); + assertEquals("测点1", captionText(out.get(0))); + assertTrue(unwrap(out.get(1)) instanceof Tbl); + assertEquals("测点2", captionText(out.get(2))); + assertTrue(unwrap(out.get(3)) instanceof Tbl); + } + + /** F1 边界:单表不加标题段,既有单表输出不变 */ + @Test + public void buildTables_singleTable_noCaption() { + Docx4jUtil.HeadingContent tpl = template("time", "standard"); + Map row = new HashMap<>(); + row.put("time", "2"); + row.put("standard", "0.5000"); + SingleTestResult r1 = resultWithRows("0.05", Collections.singletonList(row)); + List out = ItemAnchorAssembler.buildTables("HV", Collections.singletonList(r1), tpl, + Arrays.asList("time", "standard"), f); + assertEquals(1, out.size()); + assertTrue(unwrap(out.get(0)) instanceof Tbl); + } + + /** F4:bookmarkEnd 漏成 w:tbl 直接子节点(本仓 _GoBack 崩溃同类)时,key 行定位不得 ClassCastException */ + @Test + public void buildTables_bookmarkEndAsTblChild_noClassCast() { + Docx4jUtil.HeadingContent tpl = template("standard", "test"); + @SuppressWarnings("unchecked") + Tbl tplTbl = ((JAXBElement) tpl.getSubContent().get(0)).getValue(); + CTMarkupRange end = f.createCTMarkupRange(); + end.setId(BigInteger.ONE); + tplTbl.getContent().add(f.createPBookmarkEnd(end)); + Map row1 = new HashMap<>(); + row1.put("standard", "50.0000"); + row1.put("test", "50.0001"); + SingleTestResult r = resultWithRows("0.01", Collections.singletonList(row1)); + List out = ItemAnchorAssembler.buildTables("FREQ", Collections.singletonList(r), tpl, + Arrays.asList("standard", "test"), f); + assertEquals(1, out.size()); + @SuppressWarnings("unchecked") + Tbl tbl = ((JAXBElement) out.get(0)).getValue(); + // 表头 + 数据行(key 行已删,bookmarkEnd 保留不参与行计数) + List trs = collectTrs(tbl); + assertEquals(2, trs.size()); + assertEquals("50.0000", Docx4jUtil.getTextFromCell(cellOf(trs.get(1), 0))); + } + + /** F2:模板 key 行取不到占位符属模板错误,必须显性报错而不是静默走"未测→整节隐藏" */ + @Test + public void requireTableKeys_empty_throwsWithScriptCode() { + try { + ItemAnchorAssembler.requireTableKeys("FREQ", Collections.emptyList()); + fail("空 tableKeys 应抛 BusinessException"); + } catch (BusinessException e) { + assertTrue(String.valueOf(e.getMessage()), String.valueOf(e.getMessage()).contains("FREQ")); + } + } + + @Test + public void requireTableKeys_nonEmpty_passes() { + ItemAnchorAssembler.requireTableKeys("FREQ", Collections.singletonList("standard")); + } + + @Test + public void extractTableKeys_itemAnchorReadsLastRowEvenWhenOldCrossHeuristicRejectsFirstRow() { + List expected = Arrays.asList("time", "standard", "testA", "testB", "testC", + "errorA", "errorB", "errorC", "errorScope", "resultA", "resultB", "resultC"); + Docx4jUtil.HeadingContent tpl = hvTemplate(expected.toArray(new String[0])); + assertTrue("old getTableFillKeys should reject this HV title row", + Docx4jUtil.getTableFillKeys(Collections.singletonList(tpl)).isEmpty()); + + assertEquals(expected, ItemAnchorAssembler.extractTableKeys(Collections.singletonList(tpl))); + } + + private Object unwrap(Object o) { + return (o instanceof JAXBElement) ? ((JAXBElement) o).getValue() : o; + } + + private int countTables(List out) { + int n = 0; + for (Object o : out) { + if (unwrap(o) instanceof Tbl) { + n++; + } + } + return n; + } + + private String captionText(Object o) { + Object v = unwrap(o); + assertTrue("相邻表之间应为段落,实际是 " + v.getClass().getSimpleName(), v instanceof P); + return Docx4jUtil.getTextFromP((P) v); + } + + private List collectTrs(Tbl tbl) { + List trs = new ArrayList<>(); + for (Object o : tbl.getContent()) { + Object v = unwrap(o); + if (v instanceof Tr) { + trs.add((Tr) v); + } + } + return trs; + } + + private Tc cellOf(Tr tr, int colIdx) { + @SuppressWarnings("unchecked") + JAXBElement tc = (JAXBElement) tr.getContent().get(colIdx); + return tc.getValue(); + } +} diff --git a/detection/src/test/java/com/njcn/gather/report/util/ItemAnchorSectionUtilTest.java b/detection/src/test/java/com/njcn/gather/report/util/ItemAnchorSectionUtilTest.java new file mode 100644 index 00000000..c84813d7 --- /dev/null +++ b/detection/src/test/java/com/njcn/gather/report/util/ItemAnchorSectionUtilTest.java @@ -0,0 +1,254 @@ +package com.njcn.gather.report.util; + +import com.njcn.gather.tools.report.util.BookmarkUtil; +import org.docx4j.openpackaging.packages.WordprocessingMLPackage; +import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart; +import org.docx4j.wml.CTBookmark; +import org.docx4j.wml.CTMarkupRange; +import org.docx4j.wml.ObjectFactory; +import org.docx4j.wml.P; +import org.docx4j.wml.R; +import org.docx4j.wml.Text; +import org.junit.Test; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ItemAnchorSectionUtilTest { + + private final ObjectFactory f = new ObjectFactory(); + + private P para(String text) { + P p = f.createP(); + R r = f.createR(); + Text t = f.createText(); + t.setValue(text); + r.getContent().add(t); + p.getContent().add(r); + return p; + } + + private void addStart(P p, long id, String name) { + CTBookmark b = f.createCTBookmark(); + b.setId(BigInteger.valueOf(id)); + b.setName(name); + p.getContent().add(0, f.createPBookmarkStart(b)); + } + + private void addEnd(P p, long id) { + CTMarkupRange e = f.createCTMarkupRange(); + e.setId(BigInteger.valueOf(id)); + p.getContent().add(f.createPBookmarkEnd(e)); + } + + private BookmarkUtil.BookmarkInfo addBookmarkPair(P p, long id, String name, MainDocumentPart mdp) { + CTBookmark b = f.createCTBookmark(); + b.setId(BigInteger.valueOf(id)); + b.setName(name); + p.getContent().add(0, f.createPBookmarkStart(b)); + addEnd(p, id); + + BookmarkUtil.BookmarkInfo info = new BookmarkUtil.BookmarkInfo(); + info.bookmark = b; + info.parentParagraph = p; + info.parentContainer = mdp; + return info; + } + + /** + * 构造文档骨架(模拟 base 第 7 节): + * [0] 7.1 标题 {start sect_GRP_FUND} + * [1] 7.1.1 标题 {start sect_V} + * [2] item_V 锚点段 {end sect_V} + * [3] 7.1.2 标题 {start sect_I} + * [4] item_I 锚点段 {end sect_I, end sect_GRP_FUND} + * [5] 7.2 标题 {start sect_FREQ} + * [6] item_FREQ 锚点 {end sect_FREQ} + */ + private MainDocumentPart buildDoc() throws Exception { + WordprocessingMLPackage pkg = WordprocessingMLPackage.createPackage(); + MainDocumentPart mdp = pkg.getMainDocumentPart(); + P p71 = para("7.1 基波测试"); + addStart(p71, 100, "sect_GRP_FUND"); + P p711 = para("7.1.1 电压"); + addStart(p711, 101, "sect_V"); + P aV = para(""); + addEnd(aV, 101); + P p712 = para("7.1.2 电流"); + addStart(p712, 102, "sect_I"); + P aI = para(""); + addEnd(aI, 102); + addEnd(aI, 100); + P p72 = para("7.2 频率"); + addStart(p72, 103, "sect_FREQ"); + P aF = para(""); + addEnd(aF, 103); + mdp.getContent().addAll(Arrays.asList(p71, p711, aV, p712, aI, p72, aF)); + return mdp; + } + + private MainDocumentPart docWithTexts(String... texts) throws Exception { + WordprocessingMLPackage pkg = WordprocessingMLPackage.createPackage(); + MainDocumentPart mdp = pkg.getMainDocumentPart(); + for (String t : texts) { + mdp.getContent().add(para(t)); + } + return mdp; + } + + @Test + public void removeSingleChildSect() throws Exception { + MainDocumentPart mdp = buildDoc(); + ItemAnchorSectionUtil.removeSectRanges(mdp, new LinkedHashSet<>(Collections.singletonList("V"))); + // 删除 [1][2] 两块,剩 5 块 + assertEquals(5, mdp.getContent().size()); + assertTrue(Docx4jUtilTextHelper.textOf(mdp, 0).startsWith("7.1 ")); + assertTrue(Docx4jUtilTextHelper.textOf(mdp, 1).startsWith("7.1.2")); + } + + @Test + public void removeWholeGroupWhenAllChildrenHidden() throws Exception { + MainDocumentPart mdp = buildDoc(); + // GRP_FUND 子节全集是 V/I/P/ANGLE;文档里只有 V、I,但隐藏集合包含全部四个 → 整组删除 + ItemAnchorSectionUtil.removeSectRanges(mdp, new LinkedHashSet<>(Arrays.asList("V", "I", "P", "ANGLE"))); + // [0]~[4] 全删,剩 7.2 两块 + assertEquals(2, mdp.getContent().size()); + assertTrue(Docx4jUtilTextHelper.textOf(mdp, 0).startsWith("7.2")); + } + + /** F6:父 sect_GRP_* 书签缺失(如 Word 重存丢书签)时,必须回退按子节各自删除,不能一个都删不掉 */ + @Test + public void removeGroup_parentRangeMissing_fallsBackToChildren() throws Exception { + WordprocessingMLPackage pkg = WordprocessingMLPackage.createPackage(); + MainDocumentPart mdp = pkg.getMainDocumentPart(); + P p71 = para("7.1 基波测试"); // 故意不挂 sect_GRP_FUND + P p711 = para("7.1.1 电压"); + addStart(p711, 101, "sect_V"); + P aV = para(""); + addEnd(aV, 101); + P p712 = para("7.1.2 电流"); + addStart(p712, 102, "sect_I"); + P aI = para(""); + addEnd(aI, 102); + P p72 = para("7.2 频率"); + addStart(p72, 103, "sect_FREQ"); + P aF = para(""); + addEnd(aF, 103); + mdp.getContent().addAll(Arrays.asList(p71, p711, aV, p712, aI, p72, aF)); + ItemAnchorSectionUtil.removeSectRanges(mdp, new LinkedHashSet<>(Arrays.asList("V", "I", "P", "ANGLE"))); + // 父范围定位失败 → 回退删 sect_V/sect_I 各自范围;组标题 7.1(无书签定位不了)与 7.2 保留 + assertEquals(3, mdp.getContent().size()); + assertTrue(Docx4jUtilTextHelper.textOf(mdp, 0).startsWith("7.1 ")); + assertTrue(Docx4jUtilTextHelper.textOf(mdp, 1).startsWith("7.2")); + } + + @Test + public void stripBookmarks_removesOnlyOurNamespace() throws Exception { + MainDocumentPart mdp = buildDoc(); + // 额外放一个第三方书签,应保留 + P other = (P) mdp.getContent().get(0); + CTBookmark foreign = f.createCTBookmark(); + foreign.setId(BigInteger.valueOf(999)); + foreign.setName("bgbh1"); + other.getContent().add(f.createPBookmarkStart(foreign)); + ItemAnchorSectionUtil.stripItemAnchorBookmarks(mdp); + // 全文不再有 sect_/item_ 书签,但 bgbh1 保留 + java.util.List names = Docx4jUtilTextHelper.allBookmarkNames(mdp); + assertEquals(Collections.singletonList("bgbh1"), names); + } + + @Test + public void removeEmptyItemAnchorParagraph_removesStandaloneItemAnchorParagraph() throws Exception { + MainDocumentPart mdp = docWithTexts("7.1.1 电压"); + P anchor = para(""); + BookmarkUtil.BookmarkInfo info = addBookmarkPair(anchor, 9000, "item_V", mdp); + addEnd(anchor, 9011); + mdp.getContent().add(anchor); + + assertTrue(ItemAnchorSectionUtil.removeEmptyItemAnchorParagraph(info)); + + assertEquals(1, mdp.getContent().size()); + assertEquals("7.1.1 电压", Docx4jUtilTextHelper.textOf(mdp, 0)); + } + + @Test + public void removeEmptyItemAnchorParagraph_keepsItemAnchorParagraphWithText() throws Exception { + MainDocumentPart mdp = docWithTexts("7.1.1 电压"); + P anchor = para("保留文本"); + BookmarkUtil.BookmarkInfo info = addBookmarkPair(anchor, 9000, "item_V", mdp); + mdp.getContent().add(anchor); + + assertTrue(!ItemAnchorSectionUtil.removeEmptyItemAnchorParagraph(info)); + + assertEquals(2, mdp.getContent().size()); + assertEquals("保留文本", Docx4jUtilTextHelper.textOf(mdp, 1)); + } + + @Test + public void removeEmptyItemAnchorParagraph_ignoresNonItemAnchor() throws Exception { + MainDocumentPart mdp = docWithTexts("7.1.1 电压"); + P anchor = para(""); + BookmarkUtil.BookmarkInfo info = addBookmarkPair(anchor, 1, "data_line", mdp); + mdp.getContent().add(anchor); + + assertTrue(!ItemAnchorSectionUtil.removeEmptyItemAnchorParagraph(info)); + + assertEquals(2, mdp.getContent().size()); + } + + @Test + public void renumber_afterMiddleRemoval() throws Exception { + // 模拟 7.1.1 已被删除:7.1.2→7.1.1,7.1.4→…按序补位;7.2 及以后不变 + MainDocumentPart mdp = docWithTexts( + "7.准确度检查:", + "7.1 基波测试", + "7.1.2 电流", + "7.1.4 相位", + "7.2 频率", + "7.6.1 频率变化影响"); + ItemAnchorSectionUtil.renumberChapter7(mdp); + assertEquals("7.准确度检查:", Docx4jUtilTextHelper.textOf(mdp, 0)); // 全角点不动 + assertEquals("7.1 基波测试", Docx4jUtilTextHelper.textOf(mdp, 1)); + assertEquals("7.1.1 电流", Docx4jUtilTextHelper.textOf(mdp, 2)); + assertEquals("7.1.2 相位", Docx4jUtilTextHelper.textOf(mdp, 3)); + assertEquals("7.2 频率", Docx4jUtilTextHelper.textOf(mdp, 4)); + assertEquals("7.2.1 频率变化影响", Docx4jUtilTextHelper.textOf(mdp, 5)); + } + + @Test + public void renumber_afterLevel2Removal() throws Exception { + // 模拟整个 7.1 组已删除:7.2→7.1,7.5.1→7.4.1 之类整体前移 + MainDocumentPart mdp = docWithTexts("7.2 频率", "7.3 闪变", "7.5 谐波", "7.5.1 谐波电压"); + ItemAnchorSectionUtil.renumberChapter7(mdp); + assertEquals("7.1 频率", Docx4jUtilTextHelper.textOf(mdp, 0)); + assertEquals("7.2 闪变", Docx4jUtilTextHelper.textOf(mdp, 1)); + assertEquals("7.3 谐波", Docx4jUtilTextHelper.textOf(mdp, 2)); + assertEquals("7.3.1 谐波电压", Docx4jUtilTextHelper.textOf(mdp, 3)); + } + + @Test + public void renumber_prefixSplitAcrossRuns() throws Exception { + // 编号被拆在两个 run 里:"7." + "2 频率" + WordprocessingMLPackage pkg = WordprocessingMLPackage.createPackage(); + MainDocumentPart mdp = pkg.getMainDocumentPart(); + P p = f.createP(); + R r1 = f.createR(); + Text t1 = f.createText(); + t1.setValue("7."); + r1.getContent().add(t1); + R r2 = f.createR(); + Text t2 = f.createText(); + t2.setValue("2 频率"); + r2.getContent().add(t2); + p.getContent().add(r1); + p.getContent().add(r2); + mdp.getContent().add(p); + ItemAnchorSectionUtil.renumberChapter7(mdp); + assertEquals("7.1 频率", Docx4jUtilTextHelper.textOf(mdp, 0)); + } +} diff --git a/detection/src/test/java/com/njcn/gather/result/util/PowerResultAssemblerTest.java b/detection/src/test/java/com/njcn/gather/result/util/PowerResultAssemblerTest.java new file mode 100644 index 00000000..d55be7f0 --- /dev/null +++ b/detection/src/test/java/com/njcn/gather/result/util/PowerResultAssemblerTest.java @@ -0,0 +1,173 @@ +package com.njcn.gather.result.util; + +import com.njcn.gather.report.pojo.enums.ItemReportKeyEnum; +import com.njcn.gather.script.pojo.vo.PqScriptDtlDataVO; +import com.njcn.gather.storage.pojo.po.SimAndDigNonHarmonicResult; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public class PowerResultAssemblerTest { + + @Test + public void assembleRows_mergesTotalPowerResultsBySort() { + List results = Arrays.asList( + total("TotW", 14, "{\"data\": -1079.364014, \"unit\": \"%\", \"isData\": 1, \"radius\": \"-0.5~0.5\", \"errorData\": -0.058888, \"resultData\": -1080}"), + total("TotVAr", 14, "{\"data\": 12.16591, \"isData\": 5, \"resultData\": 0}"), + total("TotVA", 14, "{\"data\": 1079.463013, \"isData\": 5, \"resultData\": 1080}") + ); + + List> rows = PowerResultAssembler.assembleRows(results); + + assertEquals(1, rows.size()); + Map row = rows.get(0); + assertEquals("-1079.3640", row.get(ItemReportKeyEnum.ACTIVE_POWER.getKey())); + assertEquals("12.1659", row.get(ItemReportKeyEnum.REACTIVE_POWER.getKey())); + assertEquals("1079.4630", row.get(ItemReportKeyEnum.APPARENT_POWER.getKey())); + assertEquals("-1080.0000", row.get(ItemReportKeyEnum.STANDARD.getKey())); + assertEquals("合格", row.get(ItemReportKeyEnum.RESULT.getKey())); + } + + @Test + public void assembleRows_sumsPhasePowerWhenTotalPowerIsAbsent() { + SimAndDigNonHarmonicResult result = new SimAndDigNonHarmonicResult(); + result.setAdType("W"); + result.setSort(15); + result.setAValue("{\"data\": 100.1, \"isData\": 1, \"resultData\": 100}"); + result.setBValue("{\"data\": 200.2, \"isData\": 1, \"resultData\": 200}"); + result.setCValue("{\"data\": 300.3, \"isData\": 1, \"resultData\": 300}"); + + List> rows = PowerResultAssembler.assembleRows(Arrays.asList(result)); + + assertEquals(1, rows.size()); + assertEquals("600.6000", rows.get(0).get(ItemReportKeyEnum.ACTIVE_POWER.getKey())); + } + + @Test + public void assembleRows_prefersTotalPowerWhenTotalAndPhaseBothExist() { + SimAndDigNonHarmonicResult total = total("TotW", 16, + "{\"data\": 1000.0, \"isData\": 1, \"resultData\": 1000}"); + SimAndDigNonHarmonicResult phases = new SimAndDigNonHarmonicResult(); + phases.setAdType("W"); + phases.setSort(16); + phases.setAValue("{\"data\": 1.0, \"isData\": 1, \"resultData\": 1}"); + phases.setBValue("{\"data\": 2.0, \"isData\": 1, \"resultData\": 2}"); + phases.setCValue("{\"data\": 3.0, \"isData\": 1, \"resultData\": 3}"); + + List> rows = PowerResultAssembler.assembleRows(Arrays.asList(total, phases)); + + assertEquals(1, rows.size()); + assertEquals("1000.0000", rows.get(0).get(ItemReportKeyEnum.ACTIVE_POWER.getKey())); + } + + @Test + public void assembleRows_usesTotalPowerAsPrimaryWhenPhaseResultComesFirst() { + SimAndDigNonHarmonicResult phases = new SimAndDigNonHarmonicResult(); + phases.setAdType("W"); + phases.setSort(17); + phases.setAValue("{\"data\": 1.0, \"isData\": 1, \"resultData\": 1}"); + phases.setBValue("{\"data\": 2.0, \"isData\": 1, \"resultData\": 2}"); + phases.setCValue("{\"data\": 3.0, \"isData\": 1, \"resultData\": 3}"); + SimAndDigNonHarmonicResult total = total("TotW", 17, + "{\"data\": 1000.0, \"isData\": 1, \"resultData\": 2000}"); + + List> rows = PowerResultAssembler.assembleRows(Arrays.asList(phases, total)); + + assertEquals(1, rows.size()); + assertEquals("1000.0000", rows.get(0).get(ItemReportKeyEnum.ACTIVE_POWER.getKey())); + assertEquals("2000.0000", rows.get(0).get(ItemReportKeyEnum.STANDARD.getKey())); + assertEquals("1000.0000", rows.get(0).get(ItemReportKeyEnum.TEST.getKey())); + } + + @Test + public void assembleRows_prefersTotalPowerFactorWhenTotalAndPhaseBothExist() { + SimAndDigNonHarmonicResult total = total("TotPF", 18, + "{\"data\": 0.9900, \"isData\": 1, \"resultData\": 1.0}"); + SimAndDigNonHarmonicResult phases = new SimAndDigNonHarmonicResult(); + phases.setAdType("PF"); + phases.setSort(18); + phases.setAValue("{\"data\": 0.7, \"isData\": 1, \"resultData\": 1.0}"); + phases.setBValue("{\"data\": 0.8, \"isData\": 1, \"resultData\": 1.0}"); + phases.setCValue("{\"data\": 0.9, \"isData\": 1, \"resultData\": 1.0}"); + + List> rows = PowerResultAssembler.assembleRows(Arrays.asList(total, phases)); + + assertEquals(1, rows.size()); + assertEquals("0.9900", rows.get(0).get(ItemReportKeyEnum.POWER_FACTOR.getKey())); + } + + @Test + public void assembleRows_defaultsMissingPowerKeysToSlash() { + SimAndDigNonHarmonicResult total = total("TotW", 18, + "{\"data\": 1000.0, \"isData\": 1, \"resultData\": 1000}"); + + List> rows = PowerResultAssembler.assembleRows(Arrays.asList(total)); + + assertEquals(1, rows.size()); + assertEquals("/", rows.get(0).get(ItemReportKeyEnum.STANDARD_V.getKey())); + assertEquals("/", rows.get(0).get(ItemReportKeyEnum.STANDARD_A.getKey())); + assertEquals("/", rows.get(0).get(ItemReportKeyEnum.POWER_FACTOR.getKey())); + } + + @Test + public void assembleRows_fillsStandardSourceValuesFromScriptDetailsPreferringPhaseA() { + SimAndDigNonHarmonicResult total = total("TotW", 19, + "{\"data\": 1000.0, \"isData\": 1, \"resultData\": 1000}"); + List scriptDetails = Arrays.asList( + scriptDetail(19, "VOL", "B", 58.0, 120.0), + scriptDetail(19, "CUR", "B", 6.0, 90.0), + scriptDetail(19, "VOL", "A", 57.74, 0.0), + scriptDetail(19, "CUR", "A", 5.0, -30.0) + ); + + List> rows = PowerResultAssembler.assembleRows(Arrays.asList(total), scriptDetails); + + assertEquals(1, rows.size()); + assertEquals("57.7400", rows.get(0).get(ItemReportKeyEnum.STANDARD_V.getKey())); + assertEquals("5.0000", rows.get(0).get(ItemReportKeyEnum.STANDARD_I.getKey())); + assertEquals("0.0000", rows.get(0).get(ItemReportKeyEnum.STANDARD_U_ANGLE.getKey())); + assertEquals("-30.0000", rows.get(0).get(ItemReportKeyEnum.STANDARD_I_ANGLE.getKey())); + } + + @Test + public void assembleRows_fallsBackToPhaseBForStandardSourceValuesWhenPhaseAIsAbsent() { + SimAndDigNonHarmonicResult total = total("TotW", 20, + "{\"data\": 1000.0, \"isData\": 1, \"resultData\": 1000}"); + List scriptDetails = Arrays.asList( + scriptDetail(20, "VOL", "C", 59.0, 240.0), + scriptDetail(20, "CUR", "C", 7.0, 210.0), + scriptDetail(20, "VOL", "B", 58.0, 120.0), + scriptDetail(20, "CUR", "B", 6.0, 90.0) + ); + + List> rows = PowerResultAssembler.assembleRows(Arrays.asList(total), scriptDetails); + + assertEquals(1, rows.size()); + assertEquals("58.0000", rows.get(0).get(ItemReportKeyEnum.STANDARD_V.getKey())); + assertEquals("6.0000", rows.get(0).get(ItemReportKeyEnum.STANDARD_I.getKey())); + assertEquals("120.0000", rows.get(0).get(ItemReportKeyEnum.STANDARD_U_ANGLE.getKey())); + assertEquals("90.0000", rows.get(0).get(ItemReportKeyEnum.STANDARD_I_ANGLE.getKey())); + } + + private SimAndDigNonHarmonicResult total(String code, Integer sort, String tValue) { + SimAndDigNonHarmonicResult result = new SimAndDigNonHarmonicResult(); + result.setAdType(code); + result.setSort(sort); + result.setTValue(tValue); + return result; + } + + private PqScriptDtlDataVO scriptDetail(Integer sort, String valueType, String phase, Double value, Double angle) { + PqScriptDtlDataVO detail = new PqScriptDtlDataVO(); + detail.setScriptIndex(sort); + detail.setValueType(valueType); + detail.setPhase(phase); + detail.setValue(value); + detail.setAngle(angle); + return detail; + } +} diff --git a/detection/src/test/java/com/njcn/gather/result/util/UnbalanceResultAssemblerTest.java b/detection/src/test/java/com/njcn/gather/result/util/UnbalanceResultAssemblerTest.java new file mode 100644 index 00000000..de56f6c3 --- /dev/null +++ b/detection/src/test/java/com/njcn/gather/result/util/UnbalanceResultAssemblerTest.java @@ -0,0 +1,92 @@ +package com.njcn.gather.result.util; + +import com.njcn.gather.report.pojo.enums.ItemReportKeyEnum; +import com.njcn.gather.storage.pojo.po.SimAndDigNonHarmonicResult; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public class UnbalanceResultAssemblerTest { + + @Test + public void assembleRows_mergesVoltageUnbalanceAndSequenceBySort() { + SimAndDigNonHarmonicResult unbalance = total("V_UNBAN", 31, + "{\"data\": 0.002506, \"unit\": \"%\", \"isData\": 4, \"radius\": \"-0.2~0.2\", \"errorData\": 0.002506, \"resultData\": 0}"); + SimAndDigNonHarmonicResult sequence = sequence("SeqV", 31, + "{\"data\": 57.728199, \"isData\": 5, \"resultData\": 33.31598}", + "{\"data\": 0.004355, \"isData\": 5, \"resultData\": 0}", + "{\"data\": 0.00516, \"isData\": 5, \"resultData\": 0}"); + + List> rows = UnbalanceResultAssembler.assembleRows(Arrays.asList(unbalance, sequence)); + + assertEquals(1, rows.size()); + Map row = rows.get(0); + assertEquals("0.0000", row.get(ItemReportKeyEnum.STANDARD.getKey())); + assertEquals("0.0025", row.get(ItemReportKeyEnum.TEST.getKey())); + assertEquals("0.0025", row.get(ItemReportKeyEnum.ERROR.getKey())); + assertEquals("±0.2%", row.get(ItemReportKeyEnum.ERROR_SCOPE.getKey())); + assertEquals("无法比较", row.get(ItemReportKeyEnum.RESULT.getKey())); + assertEquals("57.7282", row.get(ItemReportKeyEnum.POS_SEQ.getKey())); + assertEquals("0.0044", row.get(ItemReportKeyEnum.NEG_SEQ.getKey())); + assertEquals("0.0052", row.get(ItemReportKeyEnum.ZERO_SEQ.getKey())); + } + + @Test + public void assembleRows_mergesCurrentUnbalanceAndSequenceBySort() { + SimAndDigNonHarmonicResult unbalance = total("I_UNBAN", 12, + "{\"data\": 0.31, \"unit\": \"%\", \"isData\": 2, \"radius\": \"-0.1~0.1\", \"errorData\": 0.21, \"resultData\": 0.1}"); + SimAndDigNonHarmonicResult sequence = sequence("SeqA", 12, + "{\"data\": 4.9999, \"isData\": 5, \"resultData\": 5}", + "{\"data\": 0.0021, \"isData\": 5, \"resultData\": 0}", + "{\"data\": 0.0008, \"isData\": 5, \"resultData\": 0}"); + + List> rows = UnbalanceResultAssembler.assembleRows(Arrays.asList(sequence, unbalance)); + + assertEquals(1, rows.size()); + Map row = rows.get(0); + assertEquals("0.1000", row.get(ItemReportKeyEnum.STANDARD.getKey())); + assertEquals("0.3100", row.get(ItemReportKeyEnum.TEST.getKey())); + assertEquals("0.2100", row.get(ItemReportKeyEnum.ERROR.getKey())); + assertEquals("不合格", row.get(ItemReportKeyEnum.RESULT.getKey())); + assertEquals("4.9999", row.get(ItemReportKeyEnum.POS_SEQ.getKey())); + assertEquals("0.0021", row.get(ItemReportKeyEnum.NEG_SEQ.getKey())); + assertEquals("0.0008", row.get(ItemReportKeyEnum.ZERO_SEQ.getKey())); + } + + @Test + public void assembleRows_defaultsMissingSequenceValuesToSlash() { + SimAndDigNonHarmonicResult unbalance = total("V_UNBAN", 7, + "{\"data\": 0.05, \"unit\": \"%\", \"isData\": 1, \"radius\": \"-0.2~0.2\", \"errorData\": 0.01, \"resultData\": 0.04}"); + + List> rows = UnbalanceResultAssembler.assembleRows(Arrays.asList(unbalance)); + + assertEquals(1, rows.size()); + Map row = rows.get(0); + assertEquals("/", row.get(ItemReportKeyEnum.POS_SEQ.getKey())); + assertEquals("/", row.get(ItemReportKeyEnum.NEG_SEQ.getKey())); + assertEquals("/", row.get(ItemReportKeyEnum.ZERO_SEQ.getKey())); + assertEquals("合格", row.get(ItemReportKeyEnum.RESULT.getKey())); + } + + private SimAndDigNonHarmonicResult total(String code, Integer sort, String tValue) { + SimAndDigNonHarmonicResult result = new SimAndDigNonHarmonicResult(); + result.setAdType(code); + result.setSort(sort); + result.setTValue(tValue); + return result; + } + + private SimAndDigNonHarmonicResult sequence(String code, Integer sort, String aValue, String bValue, String cValue) { + SimAndDigNonHarmonicResult result = new SimAndDigNonHarmonicResult(); + result.setAdType(code); + result.setSort(sort); + result.setAValue(aValue); + result.setBValue(bValue); + result.setCValue(cValue); + return result; + } +} diff --git a/entrance/src/main/resources/application.yml b/entrance/src/main/resources/application.yml index f7fa75d4..4efd995d 100644 --- a/entrance/src/main/resources/application.yml +++ b/entrance/src/main/resources/application.yml @@ -6,7 +6,7 @@ spring: datasource: druid: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://192.168.1.24:13306/pqs9100?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true + url: jdbc:mysql://192.168.1.24:13306/pqs91001?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true # username: root # password: njcnpqs # url: jdbc:mysql://127.0.0.1:3306/pqs9100?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true diff --git a/storage/src/main/java/com/njcn/gather/storage/service/SimAndDigNonHarmonicService.java b/storage/src/main/java/com/njcn/gather/storage/service/SimAndDigNonHarmonicService.java index 7db890ad..35d4b9ae 100644 --- a/storage/src/main/java/com/njcn/gather/storage/service/SimAndDigNonHarmonicService.java +++ b/storage/src/main/java/com/njcn/gather/storage/service/SimAndDigNonHarmonicService.java @@ -62,6 +62,12 @@ public interface SimAndDigNonHarmonicService extends IService queryByCondition(SingleNonHarmParam param); + /** + * 按设备回路和 sort 查询所有非谐波结果,并把 adType 映射为字典 CODE。 + * 用于功率 P:同一 sort 下可能同时存在 TotW/TotVAr/TotVA 等总量结果。 + */ + List queryBySortListWithAdTypeCode(SingleNonHarmParam param); + /** * diff --git a/storage/src/main/java/com/njcn/gather/storage/service/impl/SimAndDigNonHarmonicServiceImpl.java b/storage/src/main/java/com/njcn/gather/storage/service/impl/SimAndDigNonHarmonicServiceImpl.java index f65791ab..0be90baa 100644 --- a/storage/src/main/java/com/njcn/gather/storage/service/impl/SimAndDigNonHarmonicServiceImpl.java +++ b/storage/src/main/java/com/njcn/gather/storage/service/impl/SimAndDigNonHarmonicServiceImpl.java @@ -180,6 +180,21 @@ public class SimAndDigNonHarmonicServiceImpl extends ServiceImpl queryBySortListWithAdTypeCode(SingleNonHarmParam param) { + String prefix = "ad_non_harmonic_result_"; + DynamicTableNameHandler.setTableName(prefix + param.getPlanCode()); + MPJLambdaWrapper wrapper = new MPJLambdaWrapper<>(); + wrapper.selectAll(SimAndDigNonHarmonicResult.class) + .selectAs(DictTree::getCode, SimAndDigNonHarmonicResult::getAdType) + .leftJoin(DictTree.class, DictTree::getId, SimAndDigNonHarmonicResult::getAdType) + .like(SimAndDigNonHarmonicResult::getDevMonitorId, param.getDevId() + "_" + param.getChannelNo()) + .in(SimAndDigNonHarmonicResult::getSort, param.getSortList()); + List adNonHarmonicResults = this.getBaseMapper().selectJoinList(SimAndDigNonHarmonicResult.class, wrapper); + DynamicTableNameHandler.remove(); + return adNonHarmonicResults; + } + @Override public List listAllSimAndDigRawData(String scriptId, String code, String devId) {