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 { /** * 自动编号撞号后的重试次数。 *

* 并发下两个请求会算出同一个序号,靠 {@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); } /** * 出件。 *

* 刻意**不加 {@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 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() + ")尚未填写检验判定(当前为空),不能生成报告。" + "请先在该单据上填写判定结论(合格 / 特采 / 不合格退货 / 不合格报废)后再生成"); } } /** * 模板的报告类型必须与质检单一致。 *

* 拦在这里是为了防止「来料检验单出成出货报告」这种一眼错到客户手里的低级错误; * 前端选模板时也会按类型过滤,但过滤是体验、校验才是底线。 */ 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 getSnapshot(Long id) { return validateInstanceExists(id).getDataSnapshot(); } @Override public PageResult getInstancePage(QcReportInstancePageReqVO pageReqVO) { return instanceMapper.selectPage(pageReqVO); } @Override public void deleteInstance(Long id) { validateInstanceExists(id); // 先清附件再删实例:实例一没,归档的 PDF 就成了查不到主人的文件(附件行 + 磁盘文件双份孤儿)。 // 顺序反过来的话,清理失败就再也没机会补救了(实例已不存在,不知道要删谁的附件)。 storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id); instanceMapper.deleteById(id); } /* ------------------------------ 内部 ------------------------------ */ /** * 取刚绑定的那条附件。 *

* 预览/下载地址由 system 模块签名生成,本模块不自己拼 URL,用完即可(签名会过期, * 前端要长期可用的地址应重新查附件列表)。 */ private StorageBlobRespDTO findArchived(Long id, Long blobId) { List 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)。 *

* 只为排查用:一次出件占了多久、浏览器什么状态、失败时异常栈是什么。 * 因此**写不进去不该让出件失败**,这里吞掉并记日志。 */ 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()); } /** * 渲染并落库。 *

* 生效编号要写回上下文再渲染——纸面上的报告编号与实例编号必须是同一个, * 不能出现「报告上印着一个号、系统里存着另一个号」。 */ 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+日期+流水 自动生成"); } }