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
package cn.iocoder.yudao.module.qcreport.service.instance;
 
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.api.qc.MesQcReportApi;
import cn.iocoder.yudao.module.mes.api.qc.dto.MesQcReportRespDTO;
import cn.iocoder.yudao.module.qcreport.config.QcReportPdfProperties;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateFromQcReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstancePageReqVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.instance.QcReportInstanceDO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.render.QcReportRenderRecordDO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.template.QcReportTemplateDO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.QcReportTemplateVersionDO;
import cn.iocoder.yudao.module.qcreport.dal.mysql.instance.QcReportInstanceMapper;
import cn.iocoder.yudao.module.qcreport.dal.mysql.render.QcReportRenderRecordMapper;
import cn.iocoder.yudao.module.qcreport.engine.PageSetting;
import cn.iocoder.yudao.module.qcreport.engine.QualityReportEngine;
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.ReportContextCodec;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportFields;
import cn.iocoder.yudao.module.qcreport.enums.QcReportSourceTypeEnum;
import cn.iocoder.yudao.module.qcreport.engine.render.RenderOutcome;
import cn.iocoder.yudao.module.qcreport.service.render.PdfRenderService;
import cn.iocoder.yudao.module.qcreport.service.render.PdfRenderService.PdfResult;
import cn.iocoder.yudao.module.qcreport.service.template.QcReportTemplateService;
import cn.iocoder.yudao.module.qcreport.service.version.QcReportTemplateVersionService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.storage.StorageBlobApi;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import cn.iocoder.yudao.module.system.enums.storage.StorageApplicationTypeEnum;
import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
 
import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
 
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.qcreport.enums.QcReportEnums.InstanceStatusEnum.SUCCESS;
import static cn.iocoder.yudao.module.qcreport.enums.QcReportEnums.TemplateStatusEnum.ENABLE;
 
/**
 * 智能质检报告实例 Service 实现类
 */
@Slf4j
@Service
@Validated
public class QcReportInstanceServiceImpl implements QcReportInstanceService {
 
    /**
     * 自动编号撞号后的重试次数。
     * <p>
     * 并发下两个请求会算出同一个序号,靠 {@code uk_report_no} 唯一索引挡下来重算即可;
     * 连撞 5 次已经远超「同时出一份报告」的正常并发,再失败就如实报错,不做无限重试。
     */
    private static final int REPORT_NO_MAX_ATTEMPTS = 5;
 
    /** PDF 出件的文件类型 */
    private static final String PDF_CONTENT_TYPE = "application/pdf";
 
    /** 报告 PDF 挂在附件库里的业务记录类型:与前端查询附件用的是同一个值 */
    private static final String RECORD_TYPE = StorageRecordTypeEnum.QC_REPORT_INSTANCE.getType();
 
    /** 附件用途:与前端上传走同一个默认值,附件列表接口也是按它过滤的 */
    private static final String APPLICATION = StorageApplicationTypeEnum.FILE.getType();
 
    @Resource
    private QcReportInstanceMapper instanceMapper;
 
    @Resource
    private QcReportTemplateService templateService;
 
    @Resource
    private QcReportTemplateVersionService versionService;
 
    @Resource
    private QcReportRenderRecordMapper renderRecordMapper;
 
    @Resource
    private PdfRenderService pdfRenderService;
 
    @Resource
    private QcReportPdfProperties pdfProperties;
 
    @Resource
    private StorageBlobApi storageBlobApi;
 
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
 
    @Resource
    private MesQcReportApi mesQcReportApi;
 
    @Override
    public RenderOutcome preview(QcReportInstanceGenerateReqVO reqVO) {
        QcReportTemplateVersionDO version = resolveVersion(reqVO, false);
        ReportContext context = prepareContext(reqVO);
        // 预览不生成编号:编号是出件时才确定的流水号,预览不该消耗它
        String reportNo = resolveExplicitReportNo(reqVO);
        if (reportNo != null) {
            context.getReport().setReportNo(reportNo);
        }
        return QualityReportEngine.render(version.getSchema(), context);
    }
 
    /**
     * 出件。
     * <p>
     * 刻意**不加 {@code @Transactional}**:整段只有一次 insert,没有多语句一致性需求;
     * 一旦包上事务,下面重试路径里被 catch 掉的 {@link DuplicateKeyException}
     * 可能把事务标记成 rollback-only,反而把「重试」变成「必定失败」。
     */
    @Override
    public GenerateResult generate(QcReportInstanceGenerateReqVO reqVO) {
        QcReportTemplateVersionDO version = resolveVersion(reqVO, true);
        ReportContext context = prepareContext(reqVO);
 
        String explicitNo = resolveExplicitReportNo(reqVO);
        if (explicitNo != null) {
            // 指定了编号:每次都是同一个号,重试没有意义,撞号就明确报出来
            if (instanceMapper.selectByReportNo(explicitNo) != null) {
                throw reportNoTaken(explicitNo);
            }
            try {
                return renderAndPersist(reqVO, version, context, explicitNo);
            } catch (DuplicateKeyException race) {
                throw reportNoTaken(explicitNo);
            }
        }
 
        LocalDate day = LocalDate.now();
        String prefix = ReportNoGenerator.prefix(day);
        for (int attempt = 0; attempt < REPORT_NO_MAX_ATTEMPTS; attempt++) {
            String latest = instanceMapper.selectLatestReportNoByPrefix(prefix);
            String reportNo = ReportNoGenerator.format(day,
                    ReportNoGenerator.nextSequence(latest, day));
            try {
                return renderAndPersist(reqVO, version, context, reportNo);
            } catch (DuplicateKeyException conflict) {
                // 并发下别的请求刚抢走这个号,重算序号再试
            }
        }
        throw new ServiceException(REPORT_INSTANCE_NO_DUPLICATE.getCode(),
                "自动生成报告编号时连续 " + REPORT_NO_MAX_ATTEMPTS + " 次与已有报告冲突,请稍后重试;"
                        + "也可在请求里显式指定一个未被占用的报告编号");
    }
 
    @Override
    public GenerateFromQcResult generateFromQc(QcReportInstanceGenerateFromQcReqVO reqVO) {
        QcReportSourceTypeEnum sourceType = validateQcType(reqVO.getQcType());
        MesQcReportRespDTO data = mesQcReportApi.getQcReportData(reqVO.getQcType(), reqVO.getQcId());
        if (data == null) {
            throw new ServiceException(QC_GENERATE_ORDER_NOT_EXISTS.getCode(),
                    sourceType.getName() + "的质检单(ID=" + reqVO.getQcId() + ")不存在或已被删除,"
                            + "请刷新列表后重新选择");
        }
        validateOrderUsable(data, sourceType);
        validateTemplateType(reqVO.getTemplateId(), sourceType);
 
        if (CollUtil.isEmpty(data.getItems())) {
            throw new ServiceException(QC_GENERATE_NO_ITEM.getCode(),
                    sourceType.getName() + "的质检单「" + data.getQcCode() + "」"
                            + "(ID=" + data.getQcId() + ")没有任何检验指标"
                            + (data.getMissingIndicatorCount() > 0
                                    ? "(其中 " + data.getMissingIndicatorCount()
                                            + " 条明细所引用的检验指标已被删除)" : "")
                            + ",无法生成报告。请先在该单据上录入检验指标与实测值后再生成");
        }
 
        MesQcReportContextMapper.Mapped mapped =
                MesQcReportContextMapper.map(data, StrUtil.trimToNull(reqVO.getReportNo()));
 
        QcReportInstanceGenerateReqVO generateReq = new QcReportInstanceGenerateReqVO()
                .setTemplateId(reqVO.getTemplateId())
                .setVersion(reqVO.getVersion())
                .setReportNo(reqVO.getReportNo())
                .setBusinessId(String.valueOf(data.getQcId()))
                .setBusinessType(sourceType.getBusinessType())
                .setContext(mapped.context());
        GenerateResult result = generate(generateReq);
 
        // 「无判定规则」与「待判定」在快照里都表现为 result 为空串(引擎没算出结论),据此如实计数。
        // 不另算一遍判定条件:那样会和引擎的判定依据各说各话,还漏掉了「卡在规则报错上」的项。
        ReportContext frozen = ReportContextCodec.fromMap(result.instance().getDataSnapshot());
        List<InspectionItem> items = frozen.getInspectionItems();
        int undecidable = 0;
        for (InspectionItem item : items) {
            if (StrUtil.isBlank(item.getResult())) {
                undecidable++;
            }
        }
        return new GenerateFromQcResult(result.instance(), items.size(), undecidable,
                mapped.warnings(), result.errors());
    }
 
    /** 质检类型必须是四类之一;不合法时把收到的值和允许的值都摆出来 */
    private QcReportSourceTypeEnum validateQcType(Integer qcType) {
        QcReportSourceTypeEnum sourceType = QcReportSourceTypeEnum.of(qcType);
        if (sourceType == null) {
            throw new ServiceException(QC_GENERATE_TYPE_INVALID.getCode(),
                    "质检类型「" + qcType + "」不合法,只支持 IQC(来料检验,值 1)、IPQC(过程检验,值 2)、"
                            + "OQC(出货检验,值 3)、RQC(退货检验,值 4)");
        }
        return sourceType;
    }
 
    /** 单据得走完检验并且有人拍过判定,报告才有内容可登 */
    private void validateOrderUsable(MesQcReportRespDTO data, QcReportSourceTypeEnum sourceType) {
        if (!data.isFinished()) {
            throw new ServiceException(QC_GENERATE_ORDER_NOT_FINISHED.getCode(),
                    sourceType.getName() + "的质检单「" + data.getQcCode() + "」(ID=" + data.getQcId()
                            + ")当前状态为「" + data.getStatusName() + "」,尚未完成检验,不能生成报告。"
                            + "请先把该单据提交并完成检验判定后再生成");
        }
        if (data.getCheckResult() == null) {
            throw new ServiceException(QC_GENERATE_ORDER_NOT_JUDGED.getCode(),
                    sourceType.getName() + "的质检单「" + data.getQcCode() + "」(ID=" + data.getQcId()
                            + ")尚未填写检验判定(当前为空),不能生成报告。"
                            + "请先在该单据上填写判定结论(合格 / 特采 / 不合格退货 / 不合格报废)后再生成");
        }
    }
 
    /**
     * 模板的报告类型必须与质检单一致。
     * <p>
     * 拦在这里是为了防止「来料检验单出成出货报告」这种一眼错到客户手里的低级错误;
     * 前端选模板时也会按类型过滤,但过滤是体验、校验才是底线。
     */
    private void validateTemplateType(Long templateId, QcReportSourceTypeEnum sourceType) {
        QcReportTemplateDO template = templateService.validateTemplateExists(templateId);
        String reportType = StrUtil.trimToNull(template.getReportType());
        if (reportType == null) {
            throw new ServiceException(QC_GENERATE_TEMPLATE_TYPE_MISMATCH.getCode(),
                    "报告模板「" + template.getTemplateName() + "」(ID=" + templateId
                            + ")没有设置报告类型,无法确认它是否适用于" + sourceType.getName() + "。"
                            + "请先到报告模板中把「报告类型」设为「" + sourceType.getName() + "」后再生成");
        }
        if (!sourceType.matchesTemplateReportType(reportType)) {
            QcReportSourceTypeEnum templateType = QcReportSourceTypeEnum.of(parseType(reportType));
            throw new ServiceException(QC_GENERATE_TEMPLATE_TYPE_MISMATCH.getCode(),
                    "报告模板「" + template.getTemplateName() + "」(ID=" + templateId + ")的报告类型是「"
                            + (templateType == null ? reportType : templateType.getName())
                            + "」,与本次质检单的类型「" + sourceType.getName() + "」不一致,无法生成报告。"
                            + "请重新选择一张" + sourceType.getName() + "的报告模板");
        }
    }
 
    /** 模板上的报告类型是数字字符串;解析不出(历史脏数据)就返回 null,交给调用方回退展示原文 */
    private Integer parseType(String reportType) {
        try {
            return Integer.valueOf(reportType);
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
 
    @Override
    public GenerateResult regenerate(Long id) {
        QcReportInstanceDO instance = validateInstanceExists(id);
        if (CollUtil.isEmpty(instance.getDataSnapshot())) {
            throw exception(REPORT_INSTANCE_DATA_SNAPSHOT_MISSING);
        }
        // 用生成时的那个版本,且**不要求它现在仍是已发布**:已发布版本的内容改不了,
        // 但它可能后来被停用;一份已经出过的历史报告不该因此变成打不开。
        QcReportTemplateVersionDO version = versionService.getVersionByTemplateIdAndVersion(
                instance.getTemplateId(), instance.getTemplateVersion());
        if (version == null) {
            throw exception(TEMPLATE_VERSION_NOT_EXISTS);
        }
 
        ReportContext context = ReportContextCodec.fromMap(instance.getDataSnapshot());
        RenderOutcome outcome = QualityReportEngine.render(version.getSchema(), context);
 
        QcReportInstanceDO updateObj = new QcReportInstanceDO();
        updateObj.setId(id);
        updateObj.setRenderHtml(outcome.html());
        // 有意不覆盖 data_snapshot:冻结的数据是历史报告的依据,
        // 重新生成动的只是排版产物。引擎升级后重渲可能得到不同的 HTML,那正是「重新生成」该有的效果。
        instanceMapper.updateById(updateObj);
 
        // 排版产物换了,之前归档的 PDF 就是用旧 HTML 打的:留着它等于让用户下载到一份与页面对不上的文件。
        // 宁可作废(随时能再导一次),也不留这种静默不一致。
        storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id);
 
        return new GenerateResult(instance, outcome.errors());
    }
 
    @Override
    public PdfArchiveResult exportPdf(Long id) {
        QcReportInstanceDO instance = validateInstanceExists(id);
        if (StrUtil.isBlank(instance.getRenderHtml())) {
            throw exception(REPORT_INSTANCE_HTML_MISSING);
        }
        // 与 regenerate 同一口径:用生成时的那个版本,且不要求它现在仍是已发布,
        // 历史报告不该因为版本后来被停用就导不出
        QcReportTemplateVersionDO version = versionService.getVersionByTemplateIdAndVersion(
                instance.getTemplateId(), instance.getTemplateVersion());
        if (version == null) {
            throw exception(TEMPLATE_VERSION_NOT_EXISTS);
        }
        // 纸张几何与 HTML 里那行 @page 同源,PDF 纸面不可能和页面预览对不上
        PageSetting page = QualityReportEngine.pageOf(version.getSchema());
 
        LocalDateTime startTime = LocalDateTime.now();
        long startedAt = System.currentTimeMillis();
        try {
            // 打的是实例里已存的 HTML,不重渲:这样「详情页看到的」与「打出来的」必然出自同一份字节
            PdfResult pdf = pdfRenderService.render(instance.getRenderHtml(), page,
                    Boolean.TRUE.equals(pdfProperties.getShowPageNumber()));
 
            String fileName = instance.getReportNo() + ".pdf";
            Long blobId = storageBlobApi.saveBlob(pdf.content(), fileName, PDF_CONTENT_TYPE);
            if (blobId == null) {
                throw new ServiceException(RENDER_PDF_ARCHIVE_FAILED.getCode(),
                        RENDER_PDF_ARCHIVE_FAILED.getMsg() + ":文件名「" + fileName
                                + "」,存档后未取得文件编号,请稍后重试");
            }
            // 三步顺序是刻意的:bindAttachments 只换关联、不删旧 blob,不先删就会每导一次
            // 在磁盘上多留一份没人认领的 PDF;而「先删后存」又会在存档失败时把还好的旧 PDF 一起弄丢。
            // 所以让新 blob 先落地,再删旧的,最后绑定。
            storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id);
            storageAttachmentApi.bindAttachments(APPLICATION, RECORD_TYPE, id, List.of(blobId));
 
            long durationMs = System.currentTimeMillis() - startedAt;
            StorageBlobRespDTO archived = findArchived(id, blobId);
            saveRenderRecord(instance, startTime, durationMs, pdf.pdfDurationMs(), pdf.browserStatus(), null);
            return new PdfArchiveResult(id, instance.getReportNo(),
                    archived != null && StrUtil.isNotBlank(archived.getName()) ? archived.getName() : fileName,
                    (long) pdf.content().length,
                    blobId,
                    archived == null ? null : archived.getStorageAttachmentId(),
                    archived == null ? null : archived.getPreviewURL(),
                    archived == null ? null : archived.getDownloadURL(),
                    durationMs, pdf.pdfDurationMs(), pdf.browserStatus());
        } catch (RuntimeException e) {
            // 失败也要留痕(§41):先写一条带异常栈的记录,再把异常原样抛出,不吞
            saveRenderRecord(instance, startTime,
                    System.currentTimeMillis() - startedAt, null, null, stackTrace(e));
            throw e;
        }
    }
 
    @Override
    public QcReportInstanceDO validateInstanceExists(Long id) {
        QcReportInstanceDO instance = instanceMapper.selectById(id);
        if (instance == null) {
            throw exception(REPORT_INSTANCE_NOT_EXISTS);
        }
        return instance;
    }
 
    @Override
    public QcReportInstanceDO getInstance(Long id) {
        return instanceMapper.selectById(id);
    }
 
    @Override
    public Map<String, Object> getSnapshot(Long id) {
        return validateInstanceExists(id).getDataSnapshot();
    }
 
    @Override
    public PageResult<QcReportInstanceDO> getInstancePage(QcReportInstancePageReqVO pageReqVO) {
        return instanceMapper.selectPage(pageReqVO);
    }
 
    @Override
    public void deleteInstance(Long id) {
        validateInstanceExists(id);
        // 先清附件再删实例:实例一没,归档的 PDF 就成了查不到主人的文件(附件行 + 磁盘文件双份孤儿)。
        // 顺序反过来的话,清理失败就再也没机会补救了(实例已不存在,不知道要删谁的附件)。
        storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id);
        instanceMapper.deleteById(id);
    }
 
    /* ------------------------------ 内部 ------------------------------ */
 
    /**
     * 取刚绑定的那条附件。
     * <p>
     * 预览/下载地址由 system 模块签名生成,本模块不自己拼 URL,用完即可(签名会过期,
     * 前端要长期可用的地址应重新查附件列表)。
     */
    private StorageBlobRespDTO findArchived(Long id, Long blobId) {
        List<StorageBlobRespDTO> attachments = storageAttachmentApi.listAttachments(RECORD_TYPE, id);
        if (CollUtil.isEmpty(attachments)) {
            return null;
        }
        return attachments.stream()
                .filter(blob -> blobId.equals(blob.getId()))
                .findFirst()
                .orElse(attachments.get(0));
    }
 
    /**
     * 写渲染记录(§41)。
     * <p>
     * 只为排查用:一次出件占了多久、浏览器什么状态、失败时异常栈是什么。
     * 因此**写不进去不该让出件失败**,这里吞掉并记日志。
     */
    private void saveRenderRecord(QcReportInstanceDO instance, LocalDateTime startTime, long durationMs,
                                  Long pdfDurationMs, String browserStatus, String errorStack) {
        try {
            renderRecordMapper.insert(QcReportRenderRecordDO.builder()
                    .reportId(instance.getId())
                    .templateId(instance.getTemplateId())
                    .businessId(instance.getBusinessId())
                    .renderStartTime(startTime)
                    .renderEndTime(LocalDateTime.now())
                    .renderDuration(durationMs)
                    .pdfDuration(pdfDurationMs)
                    .browserStatus(browserStatus)
                    .errorStack(errorStack)
                    .build());
        } catch (Exception e) {
            log.warn("[saveRenderRecord][报告实例({}) 的渲染记录写入失败,忽略]", instance.getId(), e);
        }
    }
 
    private String stackTrace(Throwable e) {
        StringWriter writer = new StringWriter();
        e.printStackTrace(new PrintWriter(writer));
        return writer.toString();
    }
 
    /**
     * 定出「用哪个模板版本渲染」,并校验它真的有内容可渲。
     *
     * @param requirePublished 是否要求版本必须已发布。**出件要,预览不要**:
     *        出件会把版本号冻结进报告实例,而实例里只记版本号、不记内容,
     *        用草稿出件等于给历史报告埋一颗「版本号对得上、内容已经变了」的雷;
     *        预览不落库、不冻结数据、不消耗编号,草稿会变在这里没有任何后果,
     *        反而「保存草稿 → 点预览」正是设计器的主流程。
     */
    private QcReportTemplateVersionDO resolveVersion(QcReportInstanceGenerateReqVO reqVO, boolean requirePublished) {
        QcReportTemplateDO template = templateService.validateTemplateExists(reqVO.getTemplateId());
        if (!ENABLE.getStatus().equals(template.getStatus())) {
            // 与 createVersion 保持一致:停用的模板不再产出新报告(历史报告的重新生成不走这条路径)
            throw exception(TEMPLATE_STATUS_DISABLED);
        }
        String version = StrUtil.trimToNull(reqVO.getVersion());
        if (version == null) {
            version = StrUtil.trimToNull(template.getCurrentVersion());
            if (version == null) {
                throw exception(TEMPLATE_NO_PUBLISHED_VERSION);
            }
        }
        QcReportTemplateVersionDO versionDO = requirePublished
                ? versionService.getPublishedVersion(template.getId(), version)
                // 不校验发布状态时按「模板+版本号」直接取;取不到会返回 null(getPublishedVersion 则是抛错)
                : versionService.getVersionByTemplateIdAndVersion(template.getId(), version);
        if (versionDO == null) {
            throw exception(TEMPLATE_VERSION_NOT_EXISTS);
        }
        if (!QualityReportEngine.hasCanvas(versionDO.getSchema())) {
            // 画布是空的:渲染出来会是一张白纸,不如现在就说清楚
            throw exception(TEMPLATE_VERSION_CANVAS_EMPTY);
        }
        return versionDO;
    }
 
    /** 校验渲染入参:没有检验项的「报告」没有意义,报出来比给一张空表好 */
    private ReportContext prepareContext(QcReportInstanceGenerateReqVO reqVO) {
        ReportContext context = reqVO.getContext();
        // 请求体里显式传 "report": null 会把它置空,这里兜回默认字段集,
        // 否则 reportMap() 会在渲染深处抛 NPE,用户只看到「系统异常」
        if (context.getReport() == null) {
            context.setReport(new ReportFields());
        }
        if (CollUtil.isEmpty(context.getInspectionItems())) {
            throw new ServiceException(RENDER_BUSINESS_DATA_MISSING.getCode(),
                    "检验项列表为空,没有可判定的内容:请至少传入一个检验项(inspectionItems)再出件");
        }
        return context;
    }
 
    /** 调用方指定的编号:顶层 reportNo 优先,其次报告字段里的 reportNo */
    private String resolveExplicitReportNo(QcReportInstanceGenerateReqVO reqVO) {
        String fromRequest = StrUtil.trimToNull(reqVO.getReportNo());
        if (fromRequest != null) {
            return fromRequest;
        }
        ReportFields report = reqVO.getContext().getReport();
        return report == null ? null : StrUtil.trimToNull(report.getReportNo());
    }
 
    /**
     * 渲染并落库。
     * <p>
     * 生效编号要写回上下文再渲染——纸面上的报告编号与实例编号必须是同一个,
     * 不能出现「报告上印着一个号、系统里存着另一个号」。
     */
    private GenerateResult renderAndPersist(QcReportInstanceGenerateReqVO reqVO, QcReportTemplateVersionDO version,
                                           ReportContext context, String reportNo) {
        context.getReport().setReportNo(reportNo);
        RenderOutcome outcome = QualityReportEngine.render(version.getSchema(), context);
 
        QcReportInstanceDO instance = QcReportInstanceDO.builder()
                .reportNo(reportNo)
                .templateId(version.getTemplateId())
                .templateVersion(version.getVersion())
                .businessId(reqVO.getBusinessId())
                .businessType(reqVO.getBusinessType())
                // 冻结「判定后」的上下文:这才是产出这份 HTML 的那份数据,含算好的 PASS/FAIL 与合格率
                .dataSnapshot(outcome.context().toScope())
                .renderHtml(outcome.html())
                .status(SUCCESS.getStatus())
                .build();
        instanceMapper.insert(instance);
 
        return new GenerateResult(instance, outcome.errors());
    }
 
    private ServiceException reportNoTaken(String reportNo) {
        return new ServiceException(REPORT_INSTANCE_NO_DUPLICATE.getCode(),
                "报告编号「" + reportNo + "」已被占用,请换一个编号,或留空由系统按 QR+日期+流水 自动生成");
    }
 
}