5 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
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);
    }
 
}