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
* 拦在这里是为了防止「来料检验单出成出货报告」这种一眼错到客户手里的低级错误;
* 前端选模板时也会按类型过滤,但过滤是体验、校验才是底线。
*/
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
* 预览/下载地址由 system 模块签名生成,本模块不自己拼 URL,用完即可(签名会过期,
* 前端要长期可用的地址应重新查附件列表)。
*/
private StorageBlobRespDTO findArchived(Long id, Long blobId) {
List
* 只为排查用:一次出件占了多久、浏览器什么状态、失败时异常栈是什么。
* 因此**写不进去不该让出件失败**,这里吞掉并记日志。
*/
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+日期+流水 自动生成");
}
}