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;
|
|
/**
|
* 质检单 → 报告上下文的字段搬运。
|
* <p>
|
* 这里守的是「哪一列搬到哪儿」:搬错了不会有异常,只会让报告上印着别人的数据,
|
* 而这类错误在端到端联调里很难一眼看出来。所以每条搬运动作都在这里钉死。
|
*/
|
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<InspectionItem> 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<InspectionItem> 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);
|
}
|
|
}
|