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
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
package cn.iocoder.yudao.module.mes.api.qc;
 
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
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.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.unitmeasure.MesMdUnitMeasureDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicator.MesQcIndicatorDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicatorresult.MesQcIndicatorResultDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicatorresult.MesQcIndicatorResultDetailDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.rqc.MesQcRqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.rqc.MesQcRqcLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcCheckResultEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcIndicatorItemTypeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcResultValueTypeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.unitmeasure.MesMdUnitMeasureService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.qc.indicator.MesQcIndicatorService;
import cn.iocoder.yudao.module.mes.service.qc.indicatorresult.MesQcIndicatorResultService;
import cn.iocoder.yudao.module.mes.service.qc.iqc.MesQcIqcLineService;
import cn.iocoder.yudao.module.mes.service.qc.iqc.MesQcIqcService;
import cn.iocoder.yudao.module.mes.service.qc.ipqc.MesQcIpqcLineService;
import cn.iocoder.yudao.module.mes.service.qc.ipqc.MesQcIpqcService;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcLineService;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcService;
import cn.iocoder.yudao.module.mes.service.qc.rqc.MesQcRqcLineService;
import cn.iocoder.yudao.module.mes.service.qc.rqc.MesQcRqcService;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
 
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
 
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
 
/**
 * MES 质检单数据 API 实现类
 * <p>
 * 四类质检单的行表结构完全一致(仅父键名不同:iqc_id / ipqc_id / oqc_id / rqc_id),
 * 所以「取行」只写四个薄适配方法,映射逻辑一份共用。
 */
@Service
@Validated
public class MesQcReportApiImpl implements MesQcReportApi {
 
    /**
     * 组名合并格里「这一格让位给上面那一行」的标记值。
     * <p>
     * 报告侧把它原样写成 HTML 的 {@code hidden} 属性,所以值本身是什么不重要,
     * 重要的是它非空 —— 空串在两侧渲染器里都表示「不输出这个属性」,也就是这一格照常可见。
     */
    private static final String GROUP_CELL_HIDDEN = "hidden";
 
    @Resource
    private MesQcIqcService iqcService;
    @Resource
    private MesQcIpqcService ipqcService;
    @Resource
    private MesQcOqcService oqcService;
    @Resource
    private MesQcRqcService rqcService;
 
    @Resource
    private MesQcIqcLineService iqcLineService;
    @Resource
    private MesQcIpqcLineService ipqcLineService;
    @Resource
    private MesQcOqcLineService oqcLineService;
    @Resource
    private MesQcRqcLineService rqcLineService;
 
    @Resource
    private MesQcIndicatorResultService indicatorResultService;
    @Resource
    private MesQcIndicatorService indicatorService;
 
    @Resource
    private MesMdItemService itemService;
    @Resource
    private MesMdUnitMeasureService unitMeasureService;
    @Resource
    private MesProWorkOrderService workOrderService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private CrmCustomerApi customerApi;
 
    @Override
    public MesQcReportRespDTO getQcReportData(Integer qcType, Long qcId) {
        if (qcType == null || qcId == null) {
            return null;
        }
        MesQcReportRespDTO resp;
        List<LineRow> lines;
        Long inspectorUserId;
        if (Objects.equals(qcType, MesQcTypeEnum.IQC.getType())) {
            MesQcIqcDO order = iqcService.getIqc(qcId);
            if (order == null) {
                return null;
            }
            resp = common(qcType, order.getId(), order.getCode(), order.getName(), order.getTemplateId(),
                    order.getSourceDocType(), order.getSourceDocCode(), order.getItemId(), order.getCheckQuantity(),
                    order.getQualifiedQuantity(), order.getUnqualifiedQuantity(), order.getCheckResult(),
                    order.getInspectDate(), order.getStatus(), order.getRemark());
            resp.setVendorBatch(defaultStr(order.getVendorBatch()));
            resp.setVendorName(supplierName(order.getVendorId()));
            lines = loadIqcLines(qcId);
            inspectorUserId = order.getInspectorUserId();
        } else if (Objects.equals(qcType, MesQcTypeEnum.IPQC.getType())) {
            MesQcIpqcDO order = ipqcService.getIpqc(qcId);
            if (order == null) {
                return null;
            }
            resp = common(qcType, order.getId(), order.getCode(), order.getName(), order.getTemplateId(),
                    order.getSourceDocType(), order.getSourceDocCode(), order.getItemId(), order.getCheckQuantity(),
                    order.getQualifiedQuantity(), order.getUnqualifiedQuantity(), order.getCheckResult(),
                    order.getInspectDate(), order.getStatus(), order.getRemark());
            resp.setWorkOrderCode(workOrderCode(order.getWorkOrderId()));
            lines = loadIpqcLines(qcId);
            inspectorUserId = order.getInspectorUserId();
        } else if (Objects.equals(qcType, MesQcTypeEnum.OQC.getType())) {
            MesQcOqcDO order = oqcService.getOqc(qcId);
            if (order == null) {
                return null;
            }
            resp = common(qcType, order.getId(), order.getCode(), order.getName(), order.getTemplateId(),
                    order.getSourceDocType(), order.getSourceDocCode(), order.getItemId(), order.getCheckQuantity(),
                    order.getQualifiedQuantity(), order.getUnqualifiedQuantity(), order.getCheckResult(),
                    order.getInspectDate(), order.getStatus(), order.getRemark());
            resp.setBatchCode(defaultStr(order.getBatchCode()));
            resp.setClientName(clientName(order.getClientId()));
            lines = loadOqcLines(qcId);
            inspectorUserId = order.getInspectorUserId();
        } else if (Objects.equals(qcType, MesQcTypeEnum.RQC.getType())) {
            MesQcRqcDO order = rqcService.getRqc(qcId);
            if (order == null) {
                return null;
            }
            resp = common(qcType, order.getId(), order.getCode(), order.getName(), order.getTemplateId(),
                    order.getSourceDocType(), order.getSourceDocCode(), order.getItemId(), order.getCheckQuantity(),
                    order.getQualifiedQuantity(), order.getUnqualifiedQuantity(), order.getCheckResult(),
                    order.getInspectDate(), order.getStatus(), order.getRemark());
            resp.setBatchCode(defaultStr(order.getBatchCode()));
            lines = loadRqcLines(qcId);
            inspectorUserId = order.getInspectorUserId();
        } else {
            throw new IllegalArgumentException("暂不支持 qcType=" + qcType);
        }
 
        // 物料与单位:单位优先取行上的(检验时实际用的单位),缺失才回退物料的默认单位
        MesMdItemDO item = resp.getItemId() == null ? null
                : itemService.getItemMap(Collections.singletonList(resp.getItemId())).get(resp.getItemId());
        if (item != null) {
            resp.setItemCode(defaultStr(item.getCode()));
            resp.setItemName(defaultStr(item.getName()));
            resp.setItemSpecification(defaultStr(item.getSpecification()));
        }
        Map<Long, MesMdUnitMeasureDO> unitMap = unitMapOf(lines, item);
        if (item != null) {
            MesMdUnitMeasureDO itemUnit = unitMap.get(item.getUnitMeasureId());
            resp.setUnitName(itemUnit == null ? "" : defaultStr(itemUnit.getName()));
        }
        resp.setInspectorName(userName(inspectorUserId));
        BuiltItems built = buildItems(resp.getQcId(), qcType, lines, unitMap, item);
        resp.setItems(built.items());
        resp.setChildlessGroupNames(built.childlessGroupNames());
        resp.setSynthesizedGroupNames(built.synthesizedGroupNames());
        resp.setMissingIndicatorCount(built.missingIndicatorCount());
        return resp;
    }
 
    /** 四类单据共有的单头字段 */
    private MesQcReportRespDTO common(Integer qcType, Long qcId, String code, String name, Long templateId,
                                      Integer sourceDocType, String sourceDocCode, Long itemId, BigDecimal checkQuantity,
                                      BigDecimal qualifiedQuantity, BigDecimal unqualifiedQuantity,
                                      Integer checkResult, LocalDateTime inspectDate, Integer status, String remark) {
        return new MesQcReportRespDTO()
                .setQcType(qcType)
                .setQcTypeName(qcTypeName(qcType))
                .setQcId(qcId)
                .setQcCode(defaultStr(code))
                .setQcName(defaultStr(name))
                .setTemplateId(templateId)
                .setSourceDocType(sourceDocType)
                .setSourceDocCode(defaultStr(sourceDocCode))
                .setItemId(itemId)
                .setCheckQuantity(checkQuantity)
                .setQualifiedQuantity(qualifiedQuantity)
                .setUnqualifiedQuantity(unqualifiedQuantity)
                .setCheckResult(checkResult)
                .setCheckResultText(checkResultName(checkResult))
                .setInspectDate(inspectDate)
                .setStatus(status)
                .setStatusName(statusName(status))
                .setFinished(Objects.equals(status, MesQcStatusEnum.FINISHED.getStatus()))
                .setRemark(defaultStr(remark));
    }
 
    /**
     * 检验项 = 「样品 × 指标」一行,按分组结构排好序。
     * <p>
     * 归属只看指标的 {@code parent_id}:被本单里别的行用 parent_id 指认的分组项就是组头,
     * 它自己那一行连同所有子项行合成一组,组名在报告里纵向合并。三处刻意不这么做:
     * 不用 {@code result_type IS NULL} 判分组(方向是反的,丢掉的恰好是带公式的组名、
     * 留下的恰好是过程参数),不靠行序(行表里组头与它的子项并不相邻),也不把组头丢掉
     * (组名与公式是分组检验项最不该丢的两样东西)。
     */
    private BuiltItems buildItems(Long qcId, Integer qcType, List<LineRow> lines,
                                  Map<Long, MesMdUnitMeasureDO> unitMap, MesMdItemDO item) {
        if (CollUtil.isEmpty(lines)) {
            return new BuiltItems(Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), 0);
        }
        Map<Long, MesQcIndicatorDO> indicatorMap = indicatorService.getIndicatorMap(
                convertSet(lines, LineRow::indicatorId));
        // 样品 → 该样品的实测值明细
        Map<Long, List<MesQcIndicatorResultDetailDO>> detailMap = Collections.emptyMap();
        List<MesQcIndicatorResultDO> samples = indicatorResultService.getIndicatorResultListByQcIdAndType(qcId, qcType);
        if (CollUtil.isNotEmpty(samples)) {
            List<MesQcIndicatorResultDetailDO> details = indicatorResultService.getIndicatorResultDetailListByResultIds(
                    new ArrayList<>(convertSet(samples, MesQcIndicatorResultDO::getId)));
            detailMap = new LinkedHashMap<>();
            for (MesQcIndicatorResultDetailDO detail : details) {
                detailMap.computeIfAbsent(detail.getResultId(), k -> new ArrayList<>()).add(detail);
            }
        }
 
        // ① 行 → 指标;指标被删掉的行还原不出任何东西,只计数
        List<LineRow> liveLines = new ArrayList<>(lines.size());
        List<MesQcIndicatorDO> liveIndicators = new ArrayList<>(lines.size());
        int missingIndicatorCount = 0;
        for (LineRow line : lines) {
            MesQcIndicatorDO indicator = indicatorMap.get(line.indicatorId());
            if (indicator == null) {
                missingIndicatorCount++;
                continue;
            }
            liveLines.add(line);
            liveIndicators.add(indicator);
        }
 
        // ② 组头指标可能压根没排进本单(模板只选了子项),补取出来,否则整组的组名与公式会丢
        Map<Long, MesQcIndicatorDO> allIndicators = new LinkedHashMap<>(indicatorMap);
        Set<Long> extraIds = new LinkedHashSet<>();
        for (MesQcIndicatorDO indicator : liveIndicators) {
            Long parentId = indicator.getParentId();
            if (parentId != null && parentId != 0 && !allIndicators.containsKey(parentId)) {
                extraIds.add(parentId);
            }
        }
        if (CollUtil.isNotEmpty(extraIds)) {
            allIndicators.putAll(indicatorService.getIndicatorMap(extraIds));
        }
 
        // ③ 每一行归到「组头自己的行」或「某个组的子项行」
        Map<Long, List<Integer>> ownLinesOf = new LinkedHashMap<>();
        Map<Long, List<Integer>> childLinesOf = new LinkedHashMap<>();
        for (int i = 0; i < liveLines.size(); i++) {
            Long headId = groupHeadId(liveIndicators.get(i), allIndicators);
            if (headId == null) {
                ownLinesOf.computeIfAbsent(liveLines.get(i).indicatorId(), k -> new ArrayList<>()).add(i);
            } else {
                childLinesOf.computeIfAbsent(headId, k -> new ArrayList<>()).add(i);
            }
        }
 
        // ④ 顶层条目 = 组 + 独立项;组头那一行从独立项里摘出来当作组的锚点行
        List<String> childlessGroupNames = new ArrayList<>();
        List<String> synthesizedGroupNames = new ArrayList<>();
        List<Entry> entries = new ArrayList<>();
        for (Map.Entry<Long, List<Integer>> each : childLinesOf.entrySet()) {
            Long headId = each.getKey();
            MesQcIndicatorDO head = allIndicators.get(headId);
            List<Integer> children = sortByIndicatorOrder(each.getValue(), liveIndicators);
            List<Integer> own = ownLinesOf.remove(headId);
            if (CollUtil.isEmpty(own)) {
                synthesizedGroupNames.add(defaultStr(head.getName()));
            }
            entries.add(new Entry(head, own == null ? Collections.emptyList() : own, children,
                    children.get(0)));
        }
        for (Map.Entry<Long, List<Integer>> each : ownLinesOf.entrySet()) {
            MesQcIndicatorDO indicator = allIndicators.get(each.getKey());
            if (MesQcIndicatorItemTypeEnum.isGroup(indicator.getItemType())) {
                // 指标主数据里是分组项,本单却没有属于它的子项:照常出成独立检验项,只是组不起来
                childlessGroupNames.add(defaultStr(indicator.getName()));
            }
            entries.add(new Entry(indicator, each.getValue(), Collections.emptyList(), each.getValue().get(0)));
        }
        // 排序号缺失的排最后;同值保持行序(List.sort 稳定,比较键里再带上首行下标兜底)
        entries.sort(Comparator.comparingInt((Entry entry) -> sortKey(entry.indicator()))
                .thenComparingInt(Entry::firstLine));
 
        // ⑤ 逐条目产出行,并把分组信息下发到每一行
        List<MesQcReportItemRespDTO> items = new ArrayList<>();
        int index = 0;
        for (Entry entry : entries) {
            List<MesQcReportItemRespDTO> groupRows = new ArrayList<>();
            for (Integer lineIndex : entry.ownLines()) {
                groupRows.addAll(expandRows(liveIndicators.get(lineIndex), liveLines.get(lineIndex),
                        samples, detailMap, unitMap, item));
            }
            boolean grouped = CollUtil.isNotEmpty(entry.childLines());
            if (grouped && groupRows.isEmpty()) {
                // 组头没有自己的行:补一行,否则组名与公式无处可放
                groupRows.add(toItem(entry.indicator(), emptyLine(entry.indicator()), "", "", "", unitMap, item));
            }
            int anchorCount = groupRows.size();
            List<MesQcReportItemRespDTO> entryRows = new ArrayList<>(groupRows);
            for (Integer lineIndex : entry.childLines()) {
                for (MesQcReportItemRespDTO row : expandRows(liveIndicators.get(lineIndex), liveLines.get(lineIndex),
                        samples, detailMap, unitMap, item)) {
                    row.setGroupChildName(defaultStr(liveIndicators.get(lineIndex).getName()));
                    entryRows.add(row);
                }
            }
            int span = grouped ? entryRows.size() : 1;
            for (int i = 0; i < entryRows.size(); i++) {
                MesQcReportItemRespDTO row = entryRows.get(i);
                row.setIndex(++index);
                // 组名的合并格只挂在组的第一行,组内其余行整格隐藏、让位给它
                row.setGroupSpan(i == 0 ? span : 1);
                row.setGroupHidden(i == 0 ? "" : GROUP_CELL_HIDDEN);
                row.setRequirement(i < anchorCount
                        ? anchorRequirement(entry.indicator(), row)
                        : defaultStr(row.getStandardValue()));
                items.add(row);
            }
        }
        return new BuiltItems(items, childlessGroupNames, synthesizedGroupNames, missingIndicatorCount);
    }
 
    /** 装配结果:检验项本身,以及没能成组、被补了行而必须让调用方知道的那些 */
    private record BuiltItems(List<MesQcReportItemRespDTO> items, List<String> childlessGroupNames,
                              List<String> synthesizedGroupNames, int missingIndicatorCount) {
    }
 
    /**
     * 一个顶层条目:一个分组(组头指标 + 它的子项行),或一个独立检验项。
     *
     * @param indicator  组头的指标 / 独立项的指标
     * @param ownLines   组头自己那些行(独立项时就是它全部的行)在行表里的下标
     * @param childLines 组内子项行的下标,独立项恒为空
     * @param firstLine  条目第一行在行表里的下标,排序号相同时靠它保持行序
     */
    private record Entry(MesQcIndicatorDO indicator, List<Integer> ownLines, List<Integer> childLines,
                         int firstLine) {
    }
 
    /**
     * 这一行的组头指标编号:{@code parent_id} 指向一个「分组项」才算数。
     * <p>
     * 指向录入项属于指标主数据配置有问题,不能拿它去合并单元格;
     * 指向的指标已被删除同样不成组。
     */
    private static Long groupHeadId(MesQcIndicatorDO indicator, Map<Long, MesQcIndicatorDO> allIndicators) {
        Long parentId = indicator.getParentId();
        if (parentId == null || parentId == 0) {
            return null;
        }
        MesQcIndicatorDO head = allIndicators.get(parentId);
        return head != null && MesQcIndicatorItemTypeEnum.isGroup(head.getItemType()) ? parentId : null;
    }
 
    /** 组头的「检测要求」放公式(分组项只有公式、没有规格),没配公式就照常放标准要求 */
    private static String anchorRequirement(MesQcIndicatorDO head, MesQcReportItemRespDTO row) {
        String formula = defaultStr(head.getFormulaText());
        return formula.isEmpty() ? defaultStr(row.getStandardValue()) : formula;
    }
 
    /** 组头没排进本单时补出来的那一行:只有指标本身,没有单据行 */
    private static LineRow emptyLine(MesQcIndicatorDO head) {
        return new LineRow(head.getId(), head.getTool(), null, null, null, null, null, null);
    }
 
    /**
     * 一个检验项摊平成若干行:多样品、一个指标被录了多次实测值都会摊平。
     * 一行都没有(尚未录入实测值)时仍出一行,避免报告漏项。
     */
    private List<MesQcReportItemRespDTO> expandRows(MesQcIndicatorDO indicator, LineRow line,
                                                    List<MesQcIndicatorResultDO> samples,
                                                    Map<Long, List<MesQcIndicatorResultDetailDO>> detailMap,
                                                    Map<Long, MesMdUnitMeasureDO> unitMap, MesMdItemDO item) {
        List<MesQcReportItemRespDTO> rows = new ArrayList<>();
        for (MesQcIndicatorResultDO sample : samples) {
            for (MesQcIndicatorResultDetailDO detail : detailMap.getOrDefault(sample.getId(),
                    Collections.emptyList())) {
                if (Objects.equals(detail.getIndicatorId(), line.indicatorId())) {
                    rows.add(toItem(indicator, line, sample.getCode(), detail.getValue(), detail.getRemark(),
                            unitMap, item));
                }
            }
        }
        if (rows.isEmpty()) {
            rows.add(toItem(indicator, line, "", "", "", unitMap, item));
        }
        return rows;
    }
 
    /** 排序键:指标没设排序号的一律排最后 */
    private static int sortKey(MesQcIndicatorDO indicator) {
        Integer sortOrder = indicator.getSortOrder();
        return sortOrder == null ? Integer.MAX_VALUE : sortOrder;
    }
 
    /** 按各行的指标排序号排,同值保持行序 */
    private static List<Integer> sortByIndicatorOrder(List<Integer> lineIndexes,
                                                      List<MesQcIndicatorDO> indicators) {
        List<Integer> sorted = new ArrayList<>(lineIndexes);
        sorted.sort(Comparator.comparingInt((Integer i) -> sortKey(indicators.get(i)))
                .thenComparingInt(Integer::intValue));
        return sorted;
    }
 
    private MesQcReportItemRespDTO toItem(MesQcIndicatorDO indicator, LineRow line, String sampleCode, String value,
                                          String detailRemark, Map<Long, MesMdUnitMeasureDO> unitMap,
                                          MesMdItemDO item) {
        MesMdUnitMeasureDO unit = unitMap.get(line.unitMeasureId());
        if (unit == null && item != null) {
            unit = unitMap.get(item.getUnitMeasureId());
        }
        return new MesQcReportItemRespDTO()
                .setIndicatorId(indicator.getId())
                .setIndicatorCode(defaultStr(indicator.getCode()))
                .setIndicatorName(defaultStr(indicator.getName()))
                .setCheckMethod(defaultStr(line.checkMethod()))
                .setTool(defaultStr(line.tool()))
                .setStandardValue(plain(line.standardValue()))
                .setActualValue(defaultStr(value))
                .setUnit(unit == null ? "" : defaultStr(unit.getName()))
                .setUpperLimit(line.maxThreshold())
                .setLowerLimit(line.minThreshold())
                .setValueType(indicator.getResultType())
                .setValueTypeName(valueTypeName(indicator.getResultType()))
                .setSampleNo(defaultStr(sampleCode))
                // 默认「不是子项、不参与合并」;真子项由装配那一步改写
                .setGroupChildName("")
                .setGroupSpan(1)
                .setGroupHidden("")
                .setRemark(mergeRemark(line.remark(), detailRemark));
    }
 
    // ==================== 取行(四类同构) ====================
 
    private List<LineRow> loadIqcLines(Long qcId) {
        List<MesQcIqcLineDO> lines = iqcLineService.getIqcLineListByIqcId(qcId);
        if (CollUtil.isEmpty(lines)) {
            return Collections.emptyList();
        }
        List<LineRow> rows = new ArrayList<>(lines.size());
        for (MesQcIqcLineDO l : lines) {
            rows.add(new LineRow(l.getIndicatorId(), l.getTool(), l.getCheckMethod(), l.getStandardValue(),
                    l.getUnitMeasureId(), l.getMaxThreshold(), l.getMinThreshold(), l.getRemark()));
        }
        return rows;
    }
 
    private List<LineRow> loadIpqcLines(Long qcId) {
        List<MesQcIpqcLineDO> lines = ipqcLineService.getIpqcLineListByIpqcId(qcId);
        if (CollUtil.isEmpty(lines)) {
            return Collections.emptyList();
        }
        List<LineRow> rows = new ArrayList<>(lines.size());
        for (MesQcIpqcLineDO l : lines) {
            rows.add(new LineRow(l.getIndicatorId(), l.getTool(), l.getCheckMethod(), l.getStandardValue(),
                    l.getUnitMeasureId(), l.getMaxThreshold(), l.getMinThreshold(), l.getRemark()));
        }
        return rows;
    }
 
    private List<LineRow> loadOqcLines(Long qcId) {
        List<MesQcOqcLineDO> lines = oqcLineService.getOqcLineListByOqcId(qcId);
        if (CollUtil.isEmpty(lines)) {
            return Collections.emptyList();
        }
        List<LineRow> rows = new ArrayList<>(lines.size());
        for (MesQcOqcLineDO l : lines) {
            rows.add(new LineRow(l.getIndicatorId(), l.getTool(), l.getCheckMethod(), l.getStandardValue(),
                    l.getUnitMeasureId(), l.getMaxThreshold(), l.getMinThreshold(), l.getRemark()));
        }
        return rows;
    }
 
    private List<LineRow> loadRqcLines(Long qcId) {
        List<MesQcRqcLineDO> lines = rqcLineService.getRqcLineListByRqcId(qcId);
        if (CollUtil.isEmpty(lines)) {
            return Collections.emptyList();
        }
        List<LineRow> rows = new ArrayList<>(lines.size());
        for (MesQcRqcLineDO l : lines) {
            rows.add(new LineRow(l.getIndicatorId(), l.getTool(), l.getCheckMethod(), l.getStandardValue(),
                    l.getUnitMeasureId(), l.getMaxThreshold(), l.getMinThreshold(), l.getRemark()));
        }
        return rows;
    }
 
    // ==================== 关联名称 ====================
 
    private Map<Long, MesMdUnitMeasureDO> unitMapOf(List<LineRow> lines, MesMdItemDO item) {
        List<Long> unitIds = new ArrayList<>();
        for (LineRow line : lines) {
            if (line.unitMeasureId() != null) {
                unitIds.add(line.unitMeasureId());
            }
        }
        if (item != null && item.getUnitMeasureId() != null) {
            unitIds.add(item.getUnitMeasureId());
        }
        return unitIds.isEmpty() ? Collections.emptyMap() : unitMeasureService.getUnitMeasureMap(unitIds);
    }
 
    private String supplierName(Long vendorId) {
        if (vendorId == null) {
            return "";
        }
        List<SrmSupplierRespDTO> suppliers = srmSupplierApi.getSupplierList(Collections.singleton(vendorId))
                .getCheckedData();
        return CollUtil.isEmpty(suppliers) ? "" : defaultStr(suppliers.get(0).getName());
    }
 
    private String clientName(Long clientId) {
        if (clientId == null) {
            return "";
        }
        CrmCustomerRespDTO client = customerApi.getCustomerMap(Collections.singleton(clientId)).get(clientId);
        return client == null ? "" : defaultStr(client.getName());
    }
 
    private String workOrderCode(Long workOrderId) {
        if (workOrderId == null) {
            return "";
        }
        MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderId);
        return workOrder == null ? "" : defaultStr(workOrder.getCode());
    }
 
    private String userName(Long userId) {
        if (userId == null) {
            return "";
        }
        AdminUserRespDTO user = adminUserApi.getUserMap(Collections.singleton(userId)).get(userId);
        return user == null ? "" : defaultStr(user.getNickname());
    }
 
    // ==================== 小工具 ====================
 
    /** 去掉无意义的小数尾零,避免报告里出现 5.0000 这种由数据库精度带出来的尾巴 */
    private static String plain(BigDecimal value) {
        return value == null ? "" : value.stripTrailingZeros().toPlainString();
    }
 
    private static String mergeRemark(String lineRemark, String detailRemark) {
        if (StrUtil.isBlank(lineRemark)) {
            return defaultStr(detailRemark);
        }
        return StrUtil.isBlank(detailRemark) ? lineRemark : lineRemark + ";" + detailRemark;
    }
 
    private static String defaultStr(String value) {
        return value == null ? "" : value;
    }
 
    private static String qcTypeName(Integer qcType) {
        for (MesQcTypeEnum e : MesQcTypeEnum.values()) {
            if (Objects.equals(e.getType(), qcType)) {
                return e.getName();
            }
        }
        return "";
    }
 
    private static String statusName(Integer status) {
        for (MesQcStatusEnum e : MesQcStatusEnum.values()) {
            if (Objects.equals(e.getStatus(), status)) {
                return e.getName();
            }
        }
        return "";
    }
 
    private static String checkResultName(Integer checkResult) {
        for (MesQcCheckResultEnum e : MesQcCheckResultEnum.values()) {
            if (Objects.equals(e.getType(), checkResult)) {
                return e.getName();
            }
        }
        return "";
    }
 
    private static String valueTypeName(Integer resultType) {
        for (MesQcResultValueTypeEnum e : MesQcResultValueTypeEnum.values()) {
            if (Objects.equals(e.getType(), resultType)) {
                return e.getName();
            }
        }
        return "";
    }
 
    /** 四类行表字段完全一致,抽成统一形态后共用映射 */
    private record LineRow(Long indicatorId, String tool, String checkMethod, BigDecimal standardValue,
                           Long unitMeasureId, BigDecimal maxThreshold, BigDecimal minThreshold, String remark) {
    }
 
}