package cn.iocoder.yudao.module.qcreport.service.instance; import cn.iocoder.yudao.module.mes.api.qc.dto.MesQcReportItemRespDTO; import cn.iocoder.yudao.module.mes.api.qc.dto.MesQcReportRespDTO; import cn.iocoder.yudao.module.qcreport.engine.context.InspectionItem; import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext; import cn.iocoder.yudao.module.qcreport.engine.context.ReportFields; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; import java.util.Locale; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 质检单 → 报告上下文的字段搬运。 *

* 这里守的是「哪一列搬到哪儿」:搬错了不会有异常,只会让报告上印着别人的数据, * 而这类错误在端到端联调里很难一眼看出来。所以每条搬运动作都在这里钉死。 */ class MesQcReportContextMapperTest { @Test @DisplayName("单头字段各归各位") void mapsHeaderFields() { MesQcReportRespDTO data = baseOrder() .setQcCode("IQC_20260917001") .setQcName("精萘来料检验-0917") .setQcTypeName("来料检验") .setCheckResult(2) .setCheckResultText("特采") .setItemCode("M-001") .setItemName("精萘") .setItemSpecification("工业级 99%") .setVendorBatch("VB-2026-0917") .setBatchCode("") .setVendorName("南通化工") .setInspectorName("张三") .setInspectDate(LocalDateTime.of(2026, 9, 17, 14, 30, 5)) .setItems(List.of(item(1, "S1", "0.09"))); ReportFields report = MesQcReportContextMapper.map(data, "QR20260919-0001").context().getReport(); assertEquals("QR20260919-0001", report.getReportNo()); assertEquals("精萘来料检验-0917", report.getReportName()); assertEquals("M-001", report.getProductCode()); assertEquals("精萘", report.getProductName()); assertEquals("工业级 99%", report.getSpec()); // 来料检验只有供应商批号,批次号为空,报告头「批次」要落到供应商批号上 assertEquals("VB-2026-0917", report.getBatchNo()); assertEquals("来料检验", report.getInspectType()); assertEquals("张三", report.getInspector()); assertEquals("2026-09-17 14:30:05", report.getInspectDate()); assertEquals("南通化工", report.getSupplierName()); // 人工拍的判定走 qcResult,不是引擎的 result assertEquals("2", report.getQcResult()); assertEquals("特采", report.getQcResultText()); } @Test @DisplayName("引擎负责的字段映射器一律不碰") void leavesEngineFieldsUntouched() { MesQcReportRespDTO data = baseOrder().setItems(List.of(item(1, "S1", "0.09"))); ReportFields report = MesQcReportContextMapper.map(data, "QR1").context().getReport(); // 判定结果、结论、合格率、计数都该由 ReportEvaluator 算完写回; // 映射器若在这里填了值,等于让「没验过」的报告显示合格 assertEquals("", report.getResult()); assertEquals("", report.getResultText()); assertEquals("", report.getConclusion()); assertEquals("", report.getPassRate()); assertEquals(0, report.getTotal()); assertEquals(0, report.getPassCount()); assertEquals(0, report.getFailCount()); } @Test @DisplayName("检验项字段映射,含上下限与单位") void mapsItemFields() { MesQcReportRespDTO data = baseOrder().setItems(List.of( item(1, "S1", "0.09") .setIndicatorCode("IND-30") .setIndicatorName("正丁醇含量") .setStandardValue("≤0.10") .setUnit("%") .setUpperLimit(new BigDecimal("0.10")) .setLowerLimit(new BigDecimal("0.05")) .setRemark("复测"))); InspectionItem item = onlyItem(MesQcReportContextMapper.map(data, "QR1")); assertEquals(1, item.getIndex()); assertEquals("IND-30", item.getItemCode()); assertEquals("正丁醇含量", item.getItemName()); assertEquals("≤0.10", item.getStandardValue()); assertEquals("0.09", item.getActualValue()); assertEquals("%", item.getUnit()); assertEquals(0.10, item.getUpperLimit()); assertEquals(0.05, item.getLowerLimit()); assertEquals("复测", item.getRemark()); } @Test @DisplayName("检测方法逐行搬进上下文,与检测要求各占一处") void mapsCheckMethod() { MesQcReportRespDTO data = baseOrder().setItems(List.of( item(1, "S1", "0.09") .setIndicatorName("水分") .setCheckMethod("GB 5009.3-2016") .setRequirement("w = (m1 - m0) / m × 100%"))); InspectionItem item = onlyItem(MesQcReportContextMapper.map(data, "QR1")); // 模板里写 {{item.checkMethod}} 的「检测方法」列能不能取到值,全看这一处有没有搬; // 漏搬不会报错,只会让那一整列印成空白并在问题清单里挂一条「绑定取不到值」 assertEquals("GB 5009.3-2016", item.getCheckMethod()); assertEquals("w = (m1 - m0) / m × 100%", item.getRequirement()); } @Test @DisplayName("检测方法为空得空串,不会印成 null") void blankCheckMethodIsEmptyString() { MesQcReportRespDTO data = baseOrder().setItems(List.of(item(1, "S1", "0.09"))); assertEquals("", onlyItem(MesQcReportContextMapper.map(data, "QR1")).getCheckMethod()); } @Test @DisplayName("没有规格上下限时保持 null,不能变成 0") void keepsMissingLimitsNull() { MesQcReportRespDTO data = baseOrder().setItems(List.of(item(1, "S1", "澄清透明液体"))); InspectionItem item = onlyItem(MesQcReportContextMapper.map(data, "QR1")); // 变成 0 的话,实测值 0.09 会被判成「超上限不合格」——凭空造出一个不合格 assertNull(item.getUpperLimit()); assertNull(item.getLowerLimit()); } @Test @DisplayName("单样品:样品号进报告头,不必每行重复") void singleSampleGoesToHeaderOnly() { MesQcReportRespDTO data = baseOrder().setItems(List.of( item(1, "S1", "0.09"), item(2, "S1", "0.10"))); MesQcReportContextMapper.Mapped mapped = MesQcReportContextMapper.map(data, "QR1"); assertEquals("S1", mapped.context().getReport().getSampleNo()); assertEquals("", mapped.context().getInspectionItems().get(0).getRemark()); assertTrue(mapped.warnings().isEmpty(), "单样品不该产生样品取舍提示:" + mapped.warnings()); } @Test @DisplayName("多样品:样品号并列进报告头,同时写进每行备注,一行都不丢") void multipleSamplesFoldIntoRemark() { MesQcReportRespDTO data = baseOrder().setItems(List.of( item(1, "S1", "0.09"), item(2, "S2", "0.12").setRemark("复测"))); MesQcReportContextMapper.Mapped mapped = MesQcReportContextMapper.map(data, "QR1"); assertEquals("S1、S2", mapped.context().getReport().getSampleNo()); List items = mapped.context().getInspectionItems(); assertEquals(2, items.size()); assertEquals("样品 S1", items.get(0).getRemark()); assertEquals("样品 S2;复测", items.get(1).getRemark()); } @Test @DisplayName("样品多于三个:报告头留空并把取舍说清楚") void tooManySamplesLeaveHeaderBlank() { MesQcReportRespDTO data = baseOrder().setQcCode("OQC_20260917001").setItems(List.of( item(1, "S1", "0.09"), item(2, "S2", "0.09"), item(3, "S3", "0.09"), item(4, "S4", "0.09"))); MesQcReportContextMapper.Mapped mapped = MesQcReportContextMapper.map(data, "QR1"); assertEquals("", mapped.context().getReport().getSampleNo()); String warning = mapped.warnings().stream() .filter(w -> w.contains("样品")).findFirst().orElse(""); assertTrue(warning.contains("4 个样品"), warning); assertTrue(warning.contains("S1、S2、S3"), warning); // 报告头放不下,但每一行的实测值仍在 assertEquals(4, mapped.context().getInspectionItems().size()); } @Test @DisplayName("尚未录入实测值的项要出提示,而不是安静地出一行空白") void warnsOnMissingActualValue() { MesQcReportRespDTO data = baseOrder().setItems(List.of( item(1, "S1", "").setIndicatorName("酸含量"))); MesQcReportContextMapper.Mapped mapped = MesQcReportContextMapper.map(data, "QR1"); assertEquals("", onlyItem(mapped).getActualValue()); assertTrue(mapped.warnings().stream().anyMatch(w -> w.contains("酸含量") && w.contains("尚未录入实测值")), "应提示是哪一项没录实测值:" + mapped.warnings()); } @Test @DisplayName("分组信息逐项下发:组头拿合并行数与公式,子项拿自己的名字并让出合并格") void mapsGroupStructure() { MesQcReportRespDTO data = baseOrder().setItems(List.of( anchor(1, "水分").setRequirement("w = (m1 - m0) / m × 100%").setGroupSpan(3), child(2, "试样质量 m").setActualValue("100.5"), child(3, "水分结果 w").setActualValue("12.3"))); List items = MesQcReportContextMapper.map(data, "QR1").context().getInspectionItems(); // 组头:公式进「检测要求」,组名格跨住整组三行 assertEquals("w = (m1 - m0) / m × 100%", items.get(0).getRequirement()); assertEquals("", items.get(0).getGroup().getChildName()); assertEquals(3, items.get(0).getGroup().getSpan()); assertEquals("", items.get(0).getGroup().getHidden()); // 子项:名字进「子项」列,组名格让给上面那一行 assertEquals("试样质量 m", items.get(1).getGroup().getChildName()); assertEquals(1, items.get(1).getGroup().getSpan()); assertEquals("hidden", items.get(1).getGroup().getHidden()); assertEquals("水分结果 w", items.get(2).getGroup().getChildName()); } @Test @DisplayName("组头的锚点行按定义没有实测值,不该报「尚未录入实测值」") void groupAnchorDoesNotWarnAboutMissingActualValue() { MesQcReportRespDTO data = baseOrder().setItems(List.of( anchor(1, "水分").setGroupSpan(2), child(2, "试样质量 m").setActualValue("100.5"))); MesQcReportContextMapper.Mapped mapped = MesQcReportContextMapper.map(data, "QR1"); // 值都在子项行上,组头报了就是误报;子项行自己没录值仍然要报 assertTrue(mapped.warnings().stream().noneMatch(w -> w.contains("水分") && w.contains("尚未录入实测值")), "组头不该被算成漏录实测值:" + mapped.warnings()); } @Test @DisplayName("没有分组的报告不带分组字段,免得给没写这些路径的模板带上空格子") void noGroupingLeavesGroupNull() { MesQcReportRespDTO data = baseOrder().setItems(List.of(item(1, "S1", "0.09"))); assertNull(onlyItem(MesQcReportContextMapper.map(data, "QR1")).getGroup()); } @Test @DisplayName("没成组的分组项、被补了行来放组名与公式的分组项、已删除的指标都要如实提示") void warnsOnSkippedRows() { MesQcReportRespDTO data = baseOrder() .setChildlessGroupNames(List.of("正丁醇含量")) .setSynthesizedGroupNames(List.of("水分", "酸含量")) .setMissingIndicatorCount(2) .setItems(List.of(item(1, "S1", "0.09"))); MesQcReportContextMapper.Mapped mapped = MesQcReportContextMapper.map(data, "QR1"); assertTrue(mapped.warnings().stream().anyMatch( w -> w.contains("正丁醇含量") && w.contains("没有属于它们的子项")), "应说明哪个分组项没能合并成组:" + mapped.warnings()); assertTrue(mapped.warnings().stream().anyMatch( w -> w.contains("水分、酸含量") && w.contains("补了一行")), "应说明为哪些组头补了行、为什么:" + mapped.warnings()); assertTrue(mapped.warnings().stream().anyMatch(w -> w.contains("2 条明细")), "应说明有多少条明细的指标已被删除:" + mapped.warnings()); } @Test @DisplayName("没有样品号时报告头与备注都留空,不硬造一个号出来") void blankSampleNoStaysBlank() { MesQcReportRespDTO data = baseOrder().setItems(List.of(item(1, "", "0.09"))); MesQcReportContextMapper.Mapped mapped = MesQcReportContextMapper.map(data, "QR1"); assertEquals("", mapped.context().getReport().getSampleNo()); assertEquals("", onlyItem(mapped).getRemark()); } @Test @DisplayName("检验日期为空得空串,日期格式不受服务器 locale 影响") void formatsDateWithoutLocaleDrift() { MesQcReportRespDTO noDate = baseOrder().setItems(List.of(item(1, "S1", "0.09"))); assertEquals("", MesQcReportContextMapper.map(noDate, "QR1").context().getReport().getInspectDate()); MesQcReportRespDTO data = baseOrder() .setInspectDate(LocalDateTime.of(2026, 9, 17, 14, 30, 5)) .setItems(List.of(item(1, "S1", "0.09"))); Locale original = Locale.getDefault(); try { // 泰历会把 2026 年写成 2569 Locale.setDefault(Locale.forLanguageTag("th-TH-u-ca-buddhist-nu-thai")); assertEquals("2026-09-17 14:30:05", MesQcReportContextMapper.map(data, "QR1").context().getReport().getInspectDate()); } finally { Locale.setDefault(original); } } @Test @DisplayName("报告编号为空时留空,由出件流程给终值") void blankReportNoStaysBlank() { MesQcReportRespDTO data = baseOrder().setItems(List.of(item(1, "S1", "0.09"))); assertEquals("", MesQcReportContextMapper.map(data, null).context().getReport().getReportNo()); } /* ------------------------------ 构造 ------------------------------ */ private static MesQcReportRespDTO baseOrder() { return new MesQcReportRespDTO() .setQcType(1) .setQcId(1024L) .setQcCode("IQC_20260917001") .setQcName("质检单-0917") .setQcTypeName("来料检验") .setItemCode("") .setItemName("") .setItemSpecification("") .setUnitName(""); } private static MesQcReportItemRespDTO item(int index, String sampleNo, String actualValue) { return new MesQcReportItemRespDTO() .setIndex(index) .setIndicatorCode("IND-" + index) .setIndicatorName("指标" + index) .setStandardValue("") .setActualValue(actualValue) .setUnit("") .setSampleNo(sampleNo) // MES 侧每一项都会带上这几个分组字段(哪怕是空串), // 渲染期靠「空串 = 不输出该属性」区分可见与隐藏,缺一个都会画错 .setRequirement("") .setCheckMethod("") .setGroupChildName("") .setGroupSpan(1) .setGroupHidden("") .setRemark(""); } /** 分组的组头行(锚点行),实测值按定义是空的,合并行数由 MES 侧算好下发 */ private static MesQcReportItemRespDTO anchor(int index, String name) { return item(index, "S1", "").setIndicatorName(name); } /** 分组里的一行子项:名字进「子项」列,组名格让给上面那一行 */ private static MesQcReportItemRespDTO child(int index, String childName) { return item(index, "S1", "").setIndicatorName(childName).setGroupChildName(childName) .setGroupHidden("hidden"); } private static InspectionItem onlyItem(MesQcReportContextMapper.Mapped mapped) { ReportContext context = mapped.context(); assertEquals(1, context.getInspectionItems().size()); return context.getInspectionItems().get(0); } }