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+日期+流水 自动生成");
|
}
|
|
}
|