package cn.iocoder.yudao.module.qcreport.service.instance; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; 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.qcreport.engine.context.InspectionItem; import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext; import cn.iocoder.yudao.module.qcreport.engine.context.ReportFields; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; /** * 质检单数据 → 报告上下文。 *

* 纯函数:只做字段搬运与文案拼装,不查库、不渲染、不知道模板长什么样。 * 之所以抽出来,是因为「哪些字段到哪儿去」是这次对接最容易出错也最值得单测的部分, * 放在 Service 里就只能靠端到端联调才发现搬错了列。 *

* 不修改引擎:报告结论仍由 {@code ReportEvaluator} 按规则/规格上下限算, * 质检单上人工拍的那个判定走 {@code report.qcResult} 单独承载,两者互不覆盖。 */ public final class MesQcReportContextMapper { /** 检验日期沿用引擎其余地方的写法,避免同一份报告里出现两种日期格式 */ private static final DateTimeFormatter INSPECT_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.ROOT); /** 报告头「样品编号」只在一个样品时才有确定含义;超过这个数就不往头上堆了 */ private static final int SAMPLE_NO_JOIN_LIMIT = 3; private MesQcReportContextMapper() { } /** * 映射结果。 * * @param context 报告上下文(检验项已就位,判定结果由引擎填) * @param warnings 软提示:取样品的取舍、没成组的分组项、为组头补出来的行、尚未录入实测值的项 */ public record Mapped(ReportContext context, List warnings) { } /** * @param data 质检单数据,来自 MES * @param reportNo 本次报告编号(出件时会换成最终生效编号,这里只为预览/渲染时纸面上有个号) */ public static Mapped map(MesQcReportRespDTO data, String reportNo) { List warnings = new ArrayList<>(); List sourceItems = data.getItems() == null ? List.of() : data.getItems(); // 单据上可能有多个样品(同一指标在不同样品上各测一次),报告头只放得下一个「样品编号」 List samples = distinctSamples(sourceItems); ReportFields report = new ReportFields() .setReportNo(StrUtil.nullToEmpty(reportNo)) .setReportName(StrUtil.nullToEmpty(data.getQcName())) .setSampleNo(reportSampleNo(samples, data.getQcCode(), warnings)) .setProductCode(StrUtil.nullToEmpty(data.getItemCode())) .setProductName(StrUtil.nullToEmpty(data.getItemName())) .setSpec(StrUtil.nullToEmpty(data.getItemSpecification())) // 来料检验的批次在供应商批号上,出货/退货在批次号上,过程检验两者都没有(只有工单) .setBatchNo(firstNonBlank(data.getBatchCode(), data.getVendorBatch())) .setWorkOrderNo(StrUtil.nullToEmpty(data.getWorkOrderCode())) .setInspectType(StrUtil.nullToEmpty(data.getQcTypeName())) .setInspector(StrUtil.nullToEmpty(data.getInspectorName())) .setInspectDate(formatDate(data.getInspectDate())) // 质检单上没有「部门」这一维度,留空而不是拿别的字段硬凑 .setCustomerName(StrUtil.nullToEmpty(data.getClientName())) .setSupplierName(StrUtil.nullToEmpty(data.getVendorName())) // 人工拍的检验判定,与引擎算出的 result/resultText/conclusion 分列两处 .setQcResult(data.getCheckResult() == null ? "" : String.valueOf(data.getCheckResult())) .setQcResultText(StrUtil.nullToEmpty(data.getCheckResultText())); boolean foldSampleIntoRemark = samples.size() > 1; // 整份报告有没有分组是个整体判断:分组信息要逐项下发(渲染期算不出「这一组几行」), // 没有分组的报告一个键都不放,别让模板里没写的路径凭空多出一层空格子 boolean grouped = sourceItems.stream().anyMatch(MesQcReportContextMapper::grouped); List items = new ArrayList<>(sourceItems.size()); for (MesQcReportItemRespDTO source : sourceItems) { items.add(toItem(source, foldSampleIntoRemark, grouped, warnings)); } appendSkipWarnings(data, warnings); return new Mapped(new ReportContext().setReport(report).setInspectionItems(items), warnings); } /** 一个 (样品, 指标) 组合 = 一行检验项;样品号在多样品时并进备注,不丢数据 */ private static InspectionItem toItem(MesQcReportItemRespDTO source, boolean foldSampleIntoRemark, boolean grouped, List warnings) { int index = source.getIndex() == null ? 0 : source.getIndex(); String itemName = StrUtil.nullToEmpty(source.getIndicatorName()); if (StrUtil.isBlank(source.getActualValue()) && !isGroupAnchor(source)) { // 单据行建好了但实测值还没录:这一行在报告上必然是「待判定」,先说清楚是哪一项。 // 组头的锚点行除外 —— 组头按定义没有实测值,值都在它的子项行上,报了就是误报 warnings.add("第 " + index + " 项「" + (StrUtil.isBlank(itemName) ? "未命名" : itemName) + "」尚未录入实测值,报告中该项将无判定结论"); } return new InspectionItem() .setIndex(index) .setItemCode(StrUtil.nullToEmpty(source.getIndicatorCode())) .setItemName(itemName) .setStandardValue(StrUtil.nullToEmpty(source.getStandardValue())) .setActualValue(StrUtil.nullToEmpty(source.getActualValue())) .setUnit(StrUtil.nullToEmpty(source.getUnit())) .setRequirement(StrUtil.nullToEmpty(source.getRequirement())) .setCheckMethod(StrUtil.nullToEmpty(source.getCheckMethod())) .setGroup(grouped ? groupOf(source) : null) .setUpperLimit(toDouble(source.getUpperLimit())) .setLowerLimit(toDouble(source.getLowerLimit())) .setRemark(mergeRemark(source, foldSampleIntoRemark)); } /** 这一行是不是某个分组的子项行(子项列上有名字) */ private static boolean grouped(MesQcReportItemRespDTO source) { return StrUtil.isNotBlank(source.getGroupChildName()) || isGroupAnchor(source); } /** * 这一行是不是分组的锚点行(组名合并格的落点)。 *

* 判据是合并行数大于 1:独立项与组内其余行的这个值都是 1。 */ private static boolean isGroupAnchor(MesQcReportItemRespDTO source) { return source.getGroupSpan() != null && source.getGroupSpan() > 1; } /** 分组信息逐项下发:三个字段缺一个,模板里对应的绑定就会取不到值 */ private static InspectionItem.Group groupOf(MesQcReportItemRespDTO source) { return new InspectionItem.Group() .setChildName(StrUtil.nullToEmpty(source.getGroupChildName())) .setSpan(source.getGroupSpan() == null ? 1 : source.getGroupSpan()) .setHidden(StrUtil.nullToEmpty(source.getGroupHidden())); } /** * 检验项备注 = 单据行备注 + 实测值备注 + (多样品时)样品号。 *

* 样品号只在多样品时才并进来:单样品时报告头已经写了,每行再重复一遍纯属噪声。 */ private static String mergeRemark(MesQcReportItemRespDTO source, boolean foldSampleIntoRemark) { List parts = new ArrayList<>(3); if (foldSampleIntoRemark && StrUtil.isNotBlank(source.getSampleNo())) { parts.add("样品 " + source.getSampleNo()); } if (StrUtil.isNotBlank(source.getRemark())) { parts.add(source.getRemark().trim()); } return String.join(";", parts); } /** 报告头「样品编号」:单样品直接放,多样品放得下就并列,放不下就交给备注并在提示里说明 */ private static String reportSampleNo(List samples, String qcCode, List warnings) { if (samples.isEmpty()) { return ""; } if (samples.size() == 1) { return samples.get(0); } if (samples.size() <= SAMPLE_NO_JOIN_LIMIT) { return String.join("、", samples); } warnings.add("质检单「" + StrUtil.nullToEmpty(qcCode) + "」共有 " + samples.size() + " 个样品(" + String.join("、", samples.subList(0, SAMPLE_NO_JOIN_LIMIT)) + " 等)," + "报告头「样品编号」留空,各样品的实测值见对应检验项的备注"); return ""; } /** 分组没成组、被补了行、指标已被删除这三种情况:报告上多了什么少了什么都要如实说 */ private static void appendSkipWarnings(MesQcReportRespDTO data, List warnings) { if (CollUtil.isNotEmpty(data.getChildlessGroupNames())) { warnings.add("以下 " + data.getChildlessGroupNames().size() + " 个指标是指标库里的分组项," + "但本单没有属于它们的子项,报告里按独立检验项列出、组名不合并:「" + String.join("、", data.getChildlessGroupNames()) + "」"); } if (CollUtil.isNotEmpty(data.getSynthesizedGroupNames())) { warnings.add("以下 " + data.getSynthesizedGroupNames().size() + " 个分组项在本单的检验明细里没有自己的行" + "(多半是检验模板只选了子项),报告里为它们各补了一行来显示组名与公式:「" + String.join("、", data.getSynthesizedGroupNames()) + "」"); } if (data.getMissingIndicatorCount() > 0) { warnings.add("单据中有 " + data.getMissingIndicatorCount() + " 条明细所引用的检验指标已被删除,无法还原成检验项,已跳过"); } } private static List distinctSamples(List items) { Set samples = new LinkedHashSet<>(); for (MesQcReportItemRespDTO item : items) { if (StrUtil.isNotBlank(item.getSampleNo())) { samples.add(item.getSampleNo().trim()); } } return new ArrayList<>(samples); } private static String formatDate(LocalDateTime value) { return value == null ? "" : INSPECT_DATE_FORMAT.format(value); } /** 规格上下限可以为空(没有区间要求),空值保持 null,不能变成 0 */ private static Double toDouble(BigDecimal value) { return value == null ? null : value.doubleValue(); } private static String firstNonBlank(String first, String second) { return StrUtil.isNotBlank(first) ? first.trim() : StrUtil.nullToEmpty(second); } }