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);
}
}