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;
|
|
/**
|
* 质检单数据 → 报告上下文。
|
* <p>
|
* 纯函数:只做字段搬运与文案拼装,不查库、不渲染、不知道模板长什么样。
|
* 之所以抽出来,是因为「哪些字段到哪儿去」是这次对接最容易出错也最值得单测的部分,
|
* 放在 Service 里就只能靠端到端联调才发现搬错了列。
|
* <p>
|
* <b>不修改引擎</b>:报告结论仍由 {@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<String> warnings) {
|
}
|
|
/**
|
* @param data 质检单数据,来自 MES
|
* @param reportNo 本次报告编号(出件时会换成最终生效编号,这里只为预览/渲染时纸面上有个号)
|
*/
|
public static Mapped map(MesQcReportRespDTO data, String reportNo) {
|
List<String> warnings = new ArrayList<>();
|
List<MesQcReportItemRespDTO> sourceItems =
|
data.getItems() == null ? List.of() : data.getItems();
|
|
// 单据上可能有多个样品(同一指标在不同样品上各测一次),报告头只放得下一个「样品编号」
|
List<String> 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<InspectionItem> 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<String> 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);
|
}
|
|
/**
|
* 这一行是不是分组的锚点行(组名合并格的落点)。
|
* <p>
|
* 判据是合并行数大于 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()));
|
}
|
|
/**
|
* 检验项备注 = 单据行备注 + 实测值备注 + (多样品时)样品号。
|
* <p>
|
* 样品号只在多样品时才并进来:单样品时报告头已经写了,每行再重复一遍纯属噪声。
|
*/
|
private static String mergeRemark(MesQcReportItemRespDTO source, boolean foldSampleIntoRemark) {
|
List<String> 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<String> samples, String qcCode, List<String> 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<String> 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<String> distinctSamples(List<MesQcReportItemRespDTO> items) {
|
Set<String> 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);
|
}
|
|
}
|