ソースを参照

feat: aqct300 补编号翻译与检验员映射,并增加订单项次字典与双斜杠过滤

Co-authored-by: Cursor <cursoragent@cursor.com>
admin 3 日 前
親
コミット
6978288fb0

+ 10 - 0
DATA-DICTIONARY-API.md

@@ -43,6 +43,7 @@
 | `erp-material-original-price` | 根据材料编号取原单价 | `czbh`、`ent` | 无 |
 | `erp-item-view` | ERP 料件视图 | `ent` | `keyword` |
 | `erp-sales-order` | 销售订单开窗 | `t100id` | `keyword` |
+| `erp-sales-order-line` | 客户订单项次 | 无 | `keyword` |
 | `erp-production-order` | 生产令开窗 | `t100id`、`userId` | 无 |
 | `erp-supplier-disabled` | ERP 供应商禁用视图 | `t100id`、`ent` | `keyword` |
 | `erp-active-item` | T100 有效料号 | 无 | `keyword` |
@@ -117,6 +118,15 @@ GET /api/dictionary/erp-sales-order?t100id=ML&keyword=SO-001
 - 仅返回存在未交数量、料号不以 `6` 开头且不属于指定排除客户的订单。
 - 返回订单、客户、料件、未交数量和开票关联信息。
 
+### 4.5.1 客户订单项次
+
+```http
+GET /api/dictionary/erp-sales-order-line?keyword=SO-001-10
+```
+
+- SQL 使用 `T100客户订单项次.sql`,公司 `86`、据点 `ML` 及原有筛选条件保持不变。
+- `keyword`:可选,普通文本,不是数组。对返回列 `docseq` 模糊匹配。例如 `keyword=SO-001-10`。
+
 ### 4.6 生产令开窗
 
 ```http

+ 37 - 0
src/main/java/com/malk/minglei/config/DuplicateSlashRequestFilter.java

@@ -0,0 +1,37 @@
+package com.malk.minglei.config;
+
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 把连续斜杠收成一个,避免 {@code //api/...} 匹配不到已注册的接口。
+ */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+public class DuplicateSlashRequestFilter extends OncePerRequestFilter {
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws ServletException, IOException {
+        String contextPath = request.getContextPath() == null ? "" : request.getContextPath();
+        String uri = request.getRequestURI();
+        String path = uri.startsWith(contextPath) ? uri.substring(contextPath.length()) : uri;
+        if (path.indexOf("//") < 0) {
+            filterChain.doFilter(request, response);
+            return;
+        }
+        String normalized = path.replaceAll("/{2,}", "/");
+        if (!normalized.startsWith("/")) {
+            normalized = "/" + normalized;
+        }
+        request.getRequestDispatcher(normalized).forward(request, response);
+    }
+}

+ 67 - 0
src/main/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImpl.java

@@ -100,6 +100,28 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
                     "and xmda003 <> 'ML03030000' and xmda003 <> 'ML03020200' and xmdastus = 'Y'";
     private static final String ERP_SALES_ORDER_COUNT_SQL = "select count(1) from (" + ERP_SALES_ORDER_DATA_SQL + ")";
 
+    // 客户订单项次直接使用 T100客户订单项次.sql。表名加 DSDATA,供字典库账号访问。
+    private static final String ERP_SALES_ORDER_LINE_DICTIONARY_CODE = "erp-sales-order-line";
+    private static final String ERP_SALES_ORDER_LINE_DATA_SQL =
+            "select xmdasite,xmdadocno||'-'||xmdcseq docseq,xmdcud010,to_char(xmdadocdt,'yyyy-mm-dd') xmdadocdt, " +
+                    "(select ooag011 from DSDATA.ooag_t where ooagent = xmdaent and ooag001=xmda002) xmda002_desc, " +
+                    "xmda003, " +
+                    "(select ooefl003 from DSDATA.ooefl_t where ooeflent = xmdaent and ooefl001 = xmda003 and ooefl002 = 'zh_CN') xmda003_desc, " +
+                    "(select pmaal003 from DSDATA.pmaal_t where pmaalent = xmdaent and pmaal001 = xmda004 and pmaal002 = 'zh_CN') xmda004_desc, " +
+                    "xmdc001, " +
+                    "(select imaal003 from DSDATA.imaal_t where imaalent = xmdcent and imaal001 = xmdc001 and imaal002 = 'zh_CN') xmdc001_desc, " +
+                    "(select imaal004 from DSDATA.imaal_t where imaalent = xmdcent and imaal001 = xmdc001 and imaal002 = 'zh_CN') xmdc001_spec, " +
+                    "xmdc002, xmdc007, xmdd006, xmdd014, xmdd015, ooff013 " +
+                    "from DSDATA.xmda_t " +
+                    "left join DSDATA.xmdc_t on xmdaent = xmdcent and xmdasite = xmdcsite and xmdadocno = xmdcdocno " +
+                    "left join DSDATA.xmdd_t on xmdcent = xmddent and xmdcsite = xmddsite and xmdcdocno = xmdddocno and xmdcseq = xmddseq " +
+                    "left join DSDATA.ooff_t on xmdcent = ooffent and xmdcdocno = ooff003 and xmdcseq = ooff004 and ooff002 = 'axmt500' " +
+                    "where xmdaent =86 and xmdasite = 'ML' and xmda003 <> 'L0501' and xmda003 <> 'G0899' and xmda003 <> 'MLJT0025' and xmda003 <> 'MLJT0006' " +
+                    "and xmdd006 > (xmdd014-xmdd015) and substr(xmdc001,1,1) <> '6' and xmda003 <> 'ML03030000' and xmda003 <> 'ML03020200' " +
+                    "and xmdastus='Y'";
+    private static final String ERP_SALES_ORDER_LINE_COUNT_SQL =
+            "select count(1) from (" + ERP_SALES_ORDER_LINE_DATA_SQL + ")";
+
     // 生产令开窗沿用销售订单主明细信息,但按用户所属部门筛选,且不限制未交数量。
     private static final String ERP_PRODUCTION_ORDER_DICTIONARY_CODE = "erp-production-order";
     private static final String ERP_PRODUCTION_ORDER_DATA_SQL =
@@ -356,6 +378,9 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         if (ERP_SALES_ORDER_DICTIONARY_CODE.equals(dictionaryCode)) {
             return queryErpSalesOrder(safeFilters);
         }
+        if (ERP_SALES_ORDER_LINE_DICTIONARY_CODE.equals(dictionaryCode)) {
+            return queryErpSalesOrderLine(safeFilters);
+        }
         if (ERP_PRODUCTION_ORDER_DICTIONARY_CODE.equals(dictionaryCode)) {
             return queryErpProductionOrder(safeFilters);
         }
@@ -512,6 +537,26 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         return queryPage(dataSql, countSql, parameters, createPageRequest());
     }
 
+    /**
+     * 按 T100客户订单项次.sql 查询,keyword 对返回列 docseq 做模糊匹配。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 客户订单项次分页记录
+     */
+    private Map<String, Object> queryErpSalesOrderLine(Map<String, String> filters) {
+        validateFilters(filters, ERP_SALES_ORDER_LINE_DICTIONARY_CODE, "keyword");
+        MapSqlParameterSource parameters = new MapSqlParameterSource();
+        String dataSql = ERP_SALES_ORDER_LINE_DATA_SQL;
+        String countSql = ERP_SALES_ORDER_LINE_COUNT_SQL;
+        String keyword = toPlainKeyword(optionalTextFilter(filters, "keyword"));
+        if (keyword != null) {
+            dataSql = "select * from (" + dataSql + ") where docseq like :keyword";
+            countSql = "select count(1) from (" + dataSql + ")";
+            parameters.addValue("keyword", createLikeValue(keyword));
+        }
+        return queryPage(dataSql, countSql, parameters, createPageRequest());
+    }
+
     /**
      * 查询指定据点及指定用户所属部门范围内的生产令开窗数据。
      * 调用方传入钉钉 userId,服务先转换为 ERP 使用的员工工号后再执行 Oracle 查询。
@@ -815,6 +860,28 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         return "%" + value + "%";
     }
 
+    /**
+     * keyword 只接受一段文本。宜搭若把搜索值包成数组,取第一个文本。
+     */
+    private String toPlainKeyword(String value) {
+        if (value == null) {
+            return null;
+        }
+        String text = value.trim();
+        if (text.startsWith("[") && text.endsWith("]") && text.length() >= 2) {
+            text = text.substring(1, text.length() - 1).trim();
+            int separator = text.indexOf(',');
+            if (separator >= 0) {
+                text = text.substring(0, separator).trim();
+            }
+            if (text.length() >= 2 && text.startsWith("\"") && text.endsWith("\"")) {
+                text = text.substring(1, text.length() - 1);
+            }
+            text = text.trim();
+        }
+        return text.isEmpty() ? null : text;
+    }
+
     /**
      * 将调用方传入的公司名称映射为 ERP 使用的公司编号。
      *

+ 226 - 13
src/main/java/com/malk/minglei/service/impl/yida/Aqct300FormPushHandler.java

@@ -2,8 +2,10 @@ package com.malk.minglei.service.impl.yida;
 
 import com.alibaba.fastjson.JSON;
 import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.ErpYidaPushMainData;
 import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.minglei.service.yida.YidaFormPushHandler;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
 import com.malk.server.common.McException;
@@ -11,6 +13,8 @@ import com.malk.server.dingtalk.DDR_New;
 import com.malk.server.dingtalk.DDConf;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
@@ -18,6 +22,7 @@ import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -25,7 +30,8 @@ import java.util.Map;
 /**
  * 检验情况审批流程(aqct300)ERP 推送到宜搭的处理器。
  * <p>
- * 仅负责发起流程;交易对象编号、料件编号、表头不良数、检验位置等缺口字段按约定丢弃。
+ * 仅负责发起流程。抽样与结果的检验项目、缺点原因按分类码查中文说明,检验标准读取 qcbd002。
+ * 交易对象编号、表头不良数、计量检验的检验位置等缺口字段按约定丢弃。
  */
 @Service
 public class Aqct300FormPushHandler implements YidaFormPushHandler {
@@ -55,19 +61,29 @@ public class Aqct300FormPushHandler implements YidaFormPushHandler {
     private static final String UNIT_FIELD_ID = "textField_mt87k1g2";
     private static final String SPEC_FIELD_ID = "textField_mt80nq6v";
     private static final String ITEM_NAME_FIELD_ID = "textField_mt82p08x";
+    /** 主表料件编号,对应 ERP mainData.qcba010。 */
+    private static final String ITEM_CODE_FIELD_ID = "textField_mvyzi1jb";
     private static final String INSPECTION_UNIT_FIELD_ID = "textField_mt82p091";
     private static final String PRODUCT_FEATURE_FIELD_ID = "textField_mt82p092";
     private static final String JUDGEMENT_FIELD_ID = "textField_mt82p094";
     private static final String INSPECTOR_NAME_FIELD_ID = "textField_mt87k1g4";
+    /** 检验员人员组件,写入花名册中的钉钉用户 ID。 */
+    private static final String INSPECTOR_FIELD_ID = "employeeField_mt87k1g9";
     private static final String SEND_QTY_FIELD_ID = "numberField_mt87k1ga";
     private static final String LOT_NO_FIELD_ID = "textField_mt87k1gb";
     private static final String PASS_QTY_FIELD_ID = "numberField_mt87k1gc";
+    /** 检验项目应用分类。 */
+    private static final String INSPECTION_ITEM_CATEGORY = "1051";
+    /** 缺点原因应用分类。 */
+    private static final String DEFECT_REASON_CATEGORY = "1053";
 
     private static final String TABLE1_FIELD_ID = "tableField_psd31ws6n";
     private static final String T1_ENT_FIELD_ID = "textField_psd32acoo";
     private static final String T1_DOC_NO_FIELD_ID = "textField_psd43y52l";
     private static final String T1_SEQ_FIELD_ID = "textField_psd44ub6a";
     private static final String T1_ITEM_FIELD_ID = "textField_psd45i4rr";
+    /** 抽样与结果的检验标准,取 qcbd_t.qcbd002。 */
+    private static final String T1_STANDARD_FIELD_ID = "textField_psd46kfhw";
     private static final String T1_ITEM_DESC_FIELD_ID = "textField_psd47bc9z";
     private static final String T1_SAMPLE_QTY_FIELD_ID = "numberField_psd48zc2c";
     private static final String T1_DEFECT_COUNT_FIELD_ID = "numberField_psd492g6k";
@@ -79,6 +95,8 @@ public class Aqct300FormPushHandler implements YidaFormPushHandler {
     private static final String T2_DOC_NO_FIELD_ID = "textField_psd4eu9ds";
     private static final String T2_SEQ_FIELD_ID = "textField_psd4fqqt8";
     private static final String T2_REASON_FIELD_ID = "textField_psd4ge7ws";
+    /** 缺点原因子表的检验项目,按 qcbe001 查分类 1053 的中文说明。 */
+    private static final String T2_ITEM_FIELD_ID = "textField_psd4hbe3f";
     private static final String T2_QTY_FIELD_ID = "numberField_psd4ipqfh";
     private static final String T2_REMARK_FIELD_ID = "textField_psd4jg5l7";
     private static final String T2_BAD_QTY_FIELD_ID = "numberField_psd4klpby";
@@ -114,9 +132,32 @@ public class Aqct300FormPushHandler implements YidaFormPushHandler {
     private final DDConf dingTalkConfiguration;
     private final YidaApplicationsProperties applications;
     private final LocalFormDepartmentEnricher departmentEnricher;
+    private final Aqct300ReferenceLookup referenceLookup;
+    private final EmployeeRosterService employeeRosterService;
 
     /**
-     * 创建检验情况审批推送处理器。
+     * 创建检验情况审批推送处理器。字典库不可用时仍发起流程,名称字段保留原编号。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     * @param departmentEnricher 部门公司和负责人填充组件
+     * @param referenceLookupProvider T100 编号翻译
+     * @param employeeRosterService 员工花名册,用于把检验员工号换成姓名和钉钉用户 ID
+     */
+    @Autowired
+    public Aqct300FormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                  DDConf dingTalkConfiguration,
+                                  YidaApplicationsProperties applications,
+                                  LocalFormDepartmentEnricher departmentEnricher,
+                                  ObjectProvider<Aqct300ReferenceLookup> referenceLookupProvider,
+                                  EmployeeRosterService employeeRosterService) {
+        this(yidaProcessOpenApiClient, dingTalkConfiguration, applications, departmentEnricher,
+                referenceLookupProvider.getIfAvailable(), employeeRosterService);
+    }
+
+    /**
+     * 创建不查询 T100 字典的检验情况审批推送处理器。
      *
      * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
      * @param dingTalkConfiguration 钉钉应用配置
@@ -127,10 +168,49 @@ public class Aqct300FormPushHandler implements YidaFormPushHandler {
                                   DDConf dingTalkConfiguration,
                                   YidaApplicationsProperties applications,
                                   LocalFormDepartmentEnricher departmentEnricher) {
+        this(yidaProcessOpenApiClient, dingTalkConfiguration, applications, departmentEnricher,
+                (Aqct300ReferenceLookup) null, (EmployeeRosterService) null);
+    }
+
+    /**
+     * 创建可注入编号翻译的检验情况审批推送处理器。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     * @param departmentEnricher 部门公司和负责人填充组件
+     * @param referenceLookup T100 编号翻译,为空时名称字段保留原编号
+     */
+    Aqct300FormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                           DDConf dingTalkConfiguration,
+                           YidaApplicationsProperties applications,
+                           LocalFormDepartmentEnricher departmentEnricher,
+                           Aqct300ReferenceLookup referenceLookup) {
+        this(yidaProcessOpenApiClient, dingTalkConfiguration, applications, departmentEnricher, referenceLookup, null);
+    }
+
+    /**
+     * 创建可注入编号翻译和花名册的检验情况审批推送处理器。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     * @param departmentEnricher 部门公司和负责人填充组件
+     * @param referenceLookup T100 编号翻译,为空时名称字段保留原编号
+     * @param employeeRosterService 员工花名册,为空时不翻译检验员
+     */
+    Aqct300FormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                           DDConf dingTalkConfiguration,
+                           YidaApplicationsProperties applications,
+                           LocalFormDepartmentEnricher departmentEnricher,
+                           Aqct300ReferenceLookup referenceLookup,
+                           EmployeeRosterService employeeRosterService) {
         this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
         this.dingTalkConfiguration = dingTalkConfiguration;
         this.applications = applications;
         this.departmentEnricher = departmentEnricher;
+        this.referenceLookup = referenceLookup;
+        this.employeeRosterService = employeeRosterService;
     }
 
     /**
@@ -205,26 +285,29 @@ public class Aqct300FormPushHandler implements YidaFormPushHandler {
         putIfNotBlank(formData, ENTERPRISE_FIELD_ID, mainData.get("qcbaent"));
         putIfNotBlank(formData, DOC_NO_FIELD_ID, docNo);
         putIfNotBlank(formData, QCBADOCNO_FIELD_ID, docNo);
-        putIfNotBlank(formData, INSPECTION_TYPE_FIELD_ID, mainData.get("qcba000"));
+        putIfNotBlank(formData, INSPECTION_TYPE_FIELD_ID, toInspectionType(mainData.get("qcba000")));
         putIfNotBlank(formData, DOC_DATE_FIELD_ID, toDateMillis(mainData.get("qcbadocdt")));
         putIfNotBlank(formData, SOURCE_QTY_FIELD_ID, toDecimal(mainData.get("qcba008")));
         putIfNotBlank(formData, UNIT_FIELD_ID, mainData.get("qcba009"));
         putIfNotBlank(formData, SPEC_FIELD_ID, mainData.get("imaal004"));
         putIfNotBlank(formData, ITEM_NAME_FIELD_ID, mainData.get("imaal003"));
+        putIfNotBlank(formData, ITEM_CODE_FIELD_ID, mainData.get("qcba010"));
         putIfNotBlank(formData, INSPECTION_UNIT_FIELD_ID, mainData.get("qcba016"));
         putIfNotBlank(formData, PRODUCT_FEATURE_FIELD_ID, mainData.get("qcba012"));
-        putIfNotBlank(formData, JUDGEMENT_FIELD_ID, mainData.get("qcba022"));
-        putIfNotBlank(formData, INSPECTOR_NAME_FIELD_ID, mainData.get("qcba024"));
+        putIfNotBlank(formData, JUDGEMENT_FIELD_ID, toJudgementResult(mainData.get("qcba022")));
+        putInspector(formData, mainData.get("qcba024"));
         putIfNotBlank(formData, SEND_QTY_FIELD_ID, toDecimal(mainData.get("qcba017")));
         putIfNotBlank(formData, LOT_NO_FIELD_ID, mainData.get("qcba034"));
         putIfNotBlank(formData, PASS_QTY_FIELD_ID, toDecimal(mainData.get("qcba023")));
 
+        String enterprise = toText(firstNonBlank(mainData.get("qcbaent"), mainData.get("gs"), mainData.get("ent")));
+        Map<String, String> referenceCache = new HashMap<String, String>();
         Map<String, Object> detailWrapper = objectToMap(request.getDetailData());
-        List<Map<String, Object>> table1 = toTable1Rows(toMapRows(detailWrapper.get("detailData1")));
+        List<Map<String, Object>> table1 = toTable1Rows(toMapRows(detailWrapper.get("detailData1")), enterprise, referenceCache);
         if (!table1.isEmpty()) {
             formData.put(TABLE1_FIELD_ID, table1);
         }
-        List<Map<String, Object>> table2 = toTable2Rows(toMapRows(detailWrapper.get("detailData2")));
+        List<Map<String, Object>> table2 = toTable2Rows(toMapRows(detailWrapper.get("detailData2")), enterprise, referenceCache);
         if (!table2.isEmpty()) {
             formData.put(TABLE2_FIELD_ID, table2);
         }
@@ -240,16 +323,22 @@ public class Aqct300FormPushHandler implements YidaFormPushHandler {
     }
 
     /**
-     * 映射子表1(检验项目判定)。
+     * 映射子表1(抽样与结果)。检验项目用 qcbd001 查分类 1051 的中文说明,检验标准取 qcbd002。
      */
-    private List<Map<String, Object>> toTable1Rows(List<Map<String, Object>> sourceRows) {
+    private List<Map<String, Object>> toTable1Rows(List<Map<String, Object>> sourceRows,
+                                                   String enterprise,
+                                                   Map<String, String> referenceCache) {
         List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
         for (Map<String, Object> source : sourceRows) {
             Map<String, Object> row = new LinkedHashMap<String, Object>();
+            String itemCode = toText(source.get("qcbd001"));
             putIfNotBlank(row, T1_ENT_FIELD_ID, source.get("qcbdent"));
             putIfNotBlank(row, T1_DOC_NO_FIELD_ID, source.get("qcbddocno"));
             putIfNotBlank(row, T1_SEQ_FIELD_ID, source.get("qcbdseq"));
-            putIfNotBlank(row, T1_ITEM_FIELD_ID, source.get("qcbd001"));
+            putIfNotBlank(row, T1_ITEM_FIELD_ID, firstNonBlank(
+                    classificationName(referenceCache, enterprise, INSPECTION_ITEM_CATEGORY, itemCode), itemCode));
+            putIfNotBlank(row, T1_STANDARD_FIELD_ID, inspectionStandard(referenceCache, enterprise,
+                    toText(source.get("qcbddocno")), toText(source.get("qcbdseq"))));
             putIfNotBlank(row, T1_ITEM_DESC_FIELD_ID, source.get("oocql004"));
             putIfNotBlank(row, T1_SAMPLE_QTY_FIELD_ID, toDecimal(source.get("qcbd009")));
             putIfNotBlank(row, T1_DEFECT_COUNT_FIELD_ID, toDecimal(source.get("qcbd010")));
@@ -263,16 +352,21 @@ public class Aqct300FormPushHandler implements YidaFormPushHandler {
     }
 
     /**
-     * 映射子表2(缺点原因)。
+     * 映射子表2(缺点原因)。缺点原因和检验项目都用 qcbe001 查分类 1053 的中文说明。
      */
-    private List<Map<String, Object>> toTable2Rows(List<Map<String, Object>> sourceRows) {
+    private List<Map<String, Object>> toTable2Rows(List<Map<String, Object>> sourceRows,
+                                                   String enterprise,
+                                                   Map<String, String> referenceCache) {
         List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
         for (Map<String, Object> source : sourceRows) {
             Map<String, Object> row = new LinkedHashMap<String, Object>();
+            String reasonCode = toText(source.get("qcbe001"));
+            String reasonName = classificationName(referenceCache, enterprise, DEFECT_REASON_CATEGORY, reasonCode);
             putIfNotBlank(row, T2_ENT_FIELD_ID, source.get("qcbeent"));
             putIfNotBlank(row, T2_DOC_NO_FIELD_ID, source.get("qcbedocno"));
             putIfNotBlank(row, T2_SEQ_FIELD_ID, source.get("qcbeseq"));
-            putIfNotBlank(row, T2_REASON_FIELD_ID, source.get("qcbe001"));
+            putIfNotBlank(row, T2_REASON_FIELD_ID, firstNonBlank(reasonName, reasonCode));
+            putIfNotBlank(row, T2_ITEM_FIELD_ID, reasonName);
             putIfNotBlank(row, T2_QTY_FIELD_ID, toDecimal(source.get("qcbe002")));
             putIfNotBlank(row, T2_REMARK_FIELD_ID, source.get("qcbe003"));
             putIfNotBlank(row, T2_BAD_QTY_FIELD_ID, toDecimal(source.get("qcbe004")));
@@ -283,6 +377,61 @@ public class Aqct300FormPushHandler implements YidaFormPushHandler {
         return rows;
     }
 
+    /**
+     * 检验员工号 qcba024 查花名册:姓名写入检验员名称,钉钉用户 ID 写入检验员。
+     */
+    private void putInspector(Map<String, Object> formData, Object employeeNoValue) {
+        String employeeNo = toText(employeeNoValue);
+        if (employeeNo == null) {
+            return;
+        }
+        if (employeeRosterService == null) {
+            throw new IllegalStateException("Employee roster service is not configured");
+        }
+        EmployeeRosterInfo employee = employeeRosterService.findByEmployeeNo(employeeNo);
+        if (employee == null) {
+            throw new IllegalArgumentException("aqct300 inspector employee not found: " + employeeNo);
+        }
+        putIfNotBlank(formData, INSPECTOR_NAME_FIELD_ID, employee.getUsername());
+        String dingtalkId = toText(employee.getDingtalkId());
+        if (dingtalkId == null) {
+            throw new IllegalArgumentException("aqct300 inspector employee has no DingTalk userId: " + employeeNo);
+        }
+        formData.put(INSPECTOR_FIELD_ID, Collections.singletonList(dingtalkId));
+    }
+
+    /**
+     * 同一张单据里重复编号只查一次。查不到时返回 null,调用方保留原编号。
+     */
+    private String classificationName(Map<String, String> cache, String enterprise, String category, String code) {
+        if (referenceLookup == null || isBlank(enterprise) || isBlank(code)) {
+            return null;
+        }
+        String key = "name|" + enterprise + "|" + category + "|" + code;
+        if (cache.containsKey(key)) {
+            return cache.get(key);
+        }
+        String name = referenceLookup.classificationName(enterprise, category, code);
+        cache.put(key, name);
+        return name;
+    }
+
+    /**
+     * 按主表企业编号和抽样与结果行的单号、项次读取检验标准。
+     */
+    private String inspectionStandard(Map<String, String> cache, String enterprise, String docNo, String seq) {
+        if (referenceLookup == null || isBlank(enterprise) || isBlank(docNo) || isBlank(seq)) {
+            return null;
+        }
+        String key = "standard|" + enterprise + "|" + docNo + "|" + seq;
+        if (cache.containsKey(key)) {
+            return cache.get(key);
+        }
+        String standard = referenceLookup.inspectionStandard(enterprise, docNo, seq);
+        cache.put(key, standard);
+        return standard;
+    }
+
     /**
      * 映射子表3(计量检验);检验位置 {@code qcbd002} 无宜搭字段,丢弃。
      */
@@ -446,6 +595,70 @@ public class Aqct300FormPushHandler implements YidaFormPushHandler {
         return isBlank(value) ? null : String.valueOf(value).trim();
     }
 
+    /**
+     * ERP 检验类型 qcba000 用数字,宜搭下拉使用检验类别名称。
+     */
+    private String toInspectionType(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        String code = String.valueOf(value).trim();
+        if (code.endsWith(".0")) {
+            code = code.substring(0, code.length() - 2);
+        }
+        if ("1".equals(code)) {
+            return "IQC";
+        }
+        if ("2".equals(code)) {
+            return "FQC";
+        }
+        if ("3".equals(code)) {
+            return "PQC";
+        }
+        if ("4".equals(code)) {
+            return "OQC";
+        }
+        if ("5".equals(code)) {
+            return "Inventory QC";
+        }
+        if ("6".equals(code)) {
+            return "RQC";
+        }
+        return code;
+    }
+
+    /**
+     * ERP 判定结果 qcba022 用数字,宜搭字段使用中文结果。
+     */
+    private String toJudgementResult(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        String code = String.valueOf(value).trim();
+        if (code.endsWith(".0")) {
+            code = code.substring(0, code.length() - 2);
+        }
+        if ("0".equals(code)) {
+            return "检验中";
+        }
+        if ("1".equals(code)) {
+            return "合格";
+        }
+        if ("2".equals(code)) {
+            return "验退";
+        }
+        if ("3".equals(code)) {
+            return "返工";
+        }
+        if ("4".equals(code)) {
+            return "特采";
+        }
+        if ("5".equals(code)) {
+            return "报废";
+        }
+        return code;
+    }
+
     private boolean isBlank(Object value) {
         if (value == null) {
             return true;

+ 103 - 0
src/main/java/com/malk/minglei/service/impl/yida/Aqct300OracleReferenceLookup.java

@@ -0,0 +1,103 @@
+package com.malk.minglei.service.impl.yida;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 从 T100 字典库把检验项目、缺点原因编号译成中文说明,并读取检验标准。
+ */
+@Service
+@ConditionalOnBean(name = "dictionaryNamedParameterJdbcTemplate")
+public class Aqct300OracleReferenceLookup implements Aqct300ReferenceLookup {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(Aqct300OracleReferenceLookup.class);
+
+    private static final String CLASSIFICATION_NAME_SQL =
+            "SELECT oocql004 FROM (" +
+                    " SELECT ql.oocql004 AS oocql004" +
+                    " FROM DSDATA.oocq_t q" +
+                    " JOIN DSDATA.oocql_t ql" +
+                    "   ON ql.oocqlent = q.oocqent" +
+                    "  AND ql.oocql001 = q.oocq001" +
+                    "  AND ql.oocql002 = q.oocq002" +
+                    " WHERE ql.oocql003 = 'zh_CN'" +
+                    "   AND TO_CHAR(q.oocqent) = :ent" +
+                    "   AND q.oocq001 = :category" +
+                    "   AND q.oocq002 = :code" +
+                    ") WHERE ROWNUM = 1";
+
+    private static final String INSPECTION_STANDARD_SQL =
+            "SELECT qcbd002 FROM (" +
+                    " SELECT qcbd002" +
+                    " FROM DSDATA.qcbd_t" +
+                    " WHERE TO_CHAR(qcbdent) = :ent" +
+                    "   AND qcbddocno = :docNo" +
+                    "   AND TO_CHAR(qcbdseq) = :seq" +
+                    ") WHERE ROWNUM = 1";
+
+    private final NamedParameterJdbcTemplate jdbcTemplate;
+
+    public Aqct300OracleReferenceLookup(
+            @Qualifier("dictionaryNamedParameterJdbcTemplate") NamedParameterJdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    @Override
+    public String classificationName(String enterprise, String category, String code) {
+        if (isBlank(enterprise) || isBlank(category) || isBlank(code)) {
+            return null;
+        }
+        MapSqlParameterSource parameters = new MapSqlParameterSource();
+        parameters.addValue("ent", enterprise.trim());
+        parameters.addValue("category", category.trim());
+        parameters.addValue("code", code.trim());
+        return queryText(CLASSIFICATION_NAME_SQL, parameters, "classification");
+    }
+
+    @Override
+    public String inspectionStandard(String enterprise, String docNo, String seq) {
+        if (isBlank(enterprise) || isBlank(docNo) || isBlank(seq)) {
+            return null;
+        }
+        MapSqlParameterSource parameters = new MapSqlParameterSource();
+        parameters.addValue("ent", enterprise.trim());
+        parameters.addValue("docNo", docNo.trim());
+        parameters.addValue("seq", normalizeSeq(seq));
+        return queryText(INSPECTION_STANDARD_SQL, parameters, "inspectionStandard");
+    }
+
+    private String queryText(String sql, MapSqlParameterSource parameters, String queryName) {
+        try {
+            List<String> values = jdbcTemplate.query(sql, parameters, (resultSet, rowNum) -> {
+                String value = resultSet.getString(1);
+                return value == null ? null : value.trim();
+            });
+            if (values.isEmpty() || isBlank(values.get(0))) {
+                return null;
+            }
+            return values.get(0);
+        } catch (RuntimeException exception) {
+            LOGGER.warn("aqct300 reference lookup failed query={} parameters={}", queryName, parameters.getValues(), exception);
+            return null;
+        }
+    }
+
+    private String normalizeSeq(String seq) {
+        String text = seq.trim();
+        if (text.matches("\\d+\\.0+")) {
+            return text.substring(0, text.indexOf('.'));
+        }
+        return text;
+    }
+
+    private boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}

+ 27 - 0
src/main/java/com/malk/minglei/service/impl/yida/Aqct300ReferenceLookup.java

@@ -0,0 +1,27 @@
+package com.malk.minglei.service.impl.yida;
+
+/**
+ * 检验情况审批发起前,按 T100 编号查询宜搭要显示的名称或检验标准。
+ */
+public interface Aqct300ReferenceLookup {
+
+    /**
+     * 按应用分类码查询中文说明。1051 是检验项目,1053 是缺点原因。
+     *
+     * @param enterprise 企业编号
+     * @param category 应用分类
+     * @param code 分类码
+     * @return 中文说明;查不到时返回 null
+     */
+    String classificationName(String enterprise, String category, String code);
+
+    /**
+     * 按检验单身键查询 qcbd002,作为抽样与结果的检验标准。
+     *
+     * @param enterprise 企业编号
+     * @param docNo 检验单号
+     * @param seq 单身项次
+     * @return qcbd002;查不到时返回 null
+     */
+    String inspectionStandard(String enterprise, String docNo, String seq);
+}

+ 48 - 3
src/main/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandler.java

@@ -49,6 +49,7 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
     private static final String REASON_DESC_FIELD_ID = "textField_mt80nq6v";
     private static final String REMARK_FIELD_ID = "textField_mt7zi4fz";
     private static final String DOC_NO_FIELD_ID = "textField_mvc41oom";
+    private static final String DOC_SLIP_FIELD_ID = "selectField_mvyv7ofx";
 
     private static final String DETAIL_TABLE_FIELD_ID = "tableField_msh186sv";
     private static final String DETAIL_SEQ_FIELD_ID = "textField_mt7zi4g1";
@@ -118,7 +119,7 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
         String systemToken = requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
         Map<String, Object> formData = toYidaFormData(request, operatorUserId);
         departmentEnricher.fill(FORM_UUID, operatorUserId, formData);
-        String title = requireText(toText(formData.get(TITLE_FIELD_ID)), "Yida process title is not configured");
+        String title = requireText(toText(formData.get(FORM_TITLE_FIELD_ID)), "Yida process title is not configured");
         Map<String, Object> body = new LinkedHashMap<String, Object>();
         body.put("appType", appType);
         body.put("systemToken", systemToken);
@@ -155,9 +156,10 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
         List<Map<String, Object>> detailRows = toMapRows(detailWrapper.get("detailData1"));
         String docNo = toText(value(main, "inbadocno"));
         String title = toText(firstNonBlank(value(main, "title", "dataTitle"), docNo == null ? null : "miscReceiptIssue-" + docNo));
+        String reasonDesc = toText(value(main, "oocql004"));
 
         Map<String, Object> formData = new LinkedHashMap<String, Object>();
-        putIfNotBlank(formData, TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, TITLE_FIELD_ID, joinText(reasonDesc, docNo));
         putIfNotBlank(formData, APPLICANT_FIELD_ID, toStringList(operatorUserId, null));
         putIfNotBlank(formData, COMPANY_FIELD_ID, firstNonBlank(value(main, "gs", "applyCompany", "company"), value(main, "inbaent")));
         putIfNotBlank(formData, DEPARTMENT_FIELD_ID, toStringList(value(main, "bm", "applyDepartment", "department", "deptId"), null));
@@ -167,9 +169,10 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
         putIfNotBlank(formData, APPLICANT_NO_FIELD_ID, value(main, "inba003"));
         putIfNotBlank(formData, DEPARTMENT_NO_FIELD_ID, value(main, "inba004"));
         putIfNotBlank(formData, REASON_CODE_FIELD_ID, value(main, "inba007"));
-        putIfNotBlank(formData, REASON_DESC_FIELD_ID, value(main, "oocql004"));
+        putIfNotBlank(formData, REASON_DESC_FIELD_ID, reasonDesc);
         putIfNotBlank(formData, REMARK_FIELD_ID, value(main, "inba008"));
         putIfNotBlank(formData, DOC_NO_FIELD_ID, docNo);
+        putIfNotBlank(formData, DOC_SLIP_FIELD_ID, extractDocSlip(detailRows));
 
         List<Map<String, Object>> rows = toDetailRows(detailRows);
         if (!rows.isEmpty()) {
@@ -179,6 +182,35 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
         return formData;
     }
 
+    /**
+     * 单据别取明细 inbbdocno 中 -ML 后的 3 个字符。单号本身以 ML 开头时同样取其后 3 位。
+     */
+    private String extractDocSlip(List<Map<String, Object>> detailRows) {
+        String docNo = null;
+        for (Map<String, Object> row : detailRows) {
+            docNo = toText(value(row, "inbbdocno"));
+            if (docNo != null) {
+                break;
+            }
+        }
+        if (docNo == null) {
+            return null;
+        }
+        int start;
+        int marker = docNo.indexOf("-ML");
+        if (marker >= 0) {
+            start = marker + "-ML".length();
+        } else if (docNo.startsWith("ML")) {
+            start = "ML".length();
+        } else {
+            return null;
+        }
+        if (docNo.length() < start + 3) {
+            return null;
+        }
+        return docNo.substring(start, start + 3);
+    }
+
     private List<Map<String, Object>> toDetailRows(List<Map<String, Object>> sourceRows) {
         List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
         for (Map<String, Object> source : sourceRows) {
@@ -293,6 +325,19 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
         return isBlank(first) ? second : first;
     }
 
+    /**
+     * 原因说明与杂收发单号直接拼接。任一侧为空时只保留另一侧。
+     */
+    private String joinText(String first, String second) {
+        if (first == null) {
+            return second;
+        }
+        if (second == null) {
+            return first;
+        }
+        return first + second;
+    }
+
     private void putIfNotBlank(Map<String, Object> target, String fieldId, Object value) {
         if (!isBlank(value)) {
             target.put(fieldId, value);

+ 35 - 0
src/test/java/com/malk/minglei/config/DuplicateSlashRequestFilterTest.java

@@ -0,0 +1,35 @@
+package com.malk.minglei.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class DuplicateSlashRequestFilterTest {
+
+    private final DuplicateSlashRequestFilter filter = new DuplicateSlashRequestFilter();
+
+    @Test
+    void forwardsDoubleSlashApiPathToRegisteredPath() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest("GET", "//api/dictionary/erp-sales-order-line");
+        request.setQueryString("keyword=SO-001-10");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        filter.doFilter(request, response, new MockFilterChain());
+
+        assertEquals("/api/dictionary/erp-sales-order-line", response.getForwardedUrl());
+    }
+
+    @Test
+    void leavesSingleSlashPathUntouched() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/dictionary/erp-sales-order-line");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        filter.doFilter(request, response, new MockFilterChain());
+
+        assertNull(response.getForwardedUrl());
+    }
+}

+ 36 - 0
src/test/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImplTest.java

@@ -301,6 +301,42 @@ class DictionaryQueryServiceImplTest {
         assertEquals(Long.valueOf(12L), result.get("total"));
     }
 
+    /**
+     * 验证客户订单项次按单号-项次模糊查询,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpSalesOrderLineFiltersDocSeq() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(3L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("keyword", "[\"SO-001-10\"]");
+
+        Map<String, Object> result = service.query("erp-sales-order-line", filters);
+
+        ArgumentCaptor<String> countSql = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<SqlParameterSource> countParameters = ArgumentCaptor.forClass(SqlParameterSource.class);
+        verify(jdbcTemplate).queryForObject(countSql.capture(), countParameters.capture(), eq(Long.class));
+        assertEquals(true, countSql.getValue().contains("xmdadocno||'-'||xmdcseq docseq"));
+        assertEquals(true, countSql.getValue().contains("where xmdaent =86 and xmdasite = 'ML'"));
+        assertEquals(true, countSql.getValue().contains("where docseq like :keyword"));
+        assertEquals("%SO-001-10%", countParameters.getValue().getValue("keyword"));
+
+        ArgumentCaptor<SqlParameterSource> pageParameters = ArgumentCaptor.forClass(SqlParameterSource.class);
+        verify(jdbcTemplate).queryForList(anyString(), pageParameters.capture());
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(3L), result.get("total"));
+    }
+
     /**
      * 验证生产令开窗按据点和用户所属部门查询,并固定返回前 100 条记录。
      */

+ 95 - 7
src/test/java/com/malk/minglei/service/impl/yida/Aqct300FormPushHandlerTest.java

@@ -3,7 +3,9 @@ package com.malk.minglei.service.impl.yida;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.ErpYidaPushMainData;
+import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.minglei.dto.ErpYidaPushRequest;
 import com.malk.minglei.service.yida.MingleiDocumentService;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
@@ -51,7 +53,7 @@ class Aqct300FormPushHandlerTest {
         mainData.setExtraField("qcbaent", "87");
         mainData.setExtraField("qcbadocno", "QC-20260921-001");
         mainData.setExtraField("qcbadocdt", "2026/09/21");
-        mainData.setExtraField("qcba000", "IQC");
+        mainData.setExtraField("qcba000", "1");
         mainData.setExtraField("qcba005", "SUP-001");
         mainData.setExtraField("qcba008", "100");
         mainData.setExtraField("qcba009", "PCS");
@@ -61,7 +63,7 @@ class Aqct300FormPushHandlerTest {
         mainData.setExtraField("qcba012", "feature-a");
         mainData.setExtraField("qcba016", "EA");
         mainData.setExtraField("qcba017", "80");
-        mainData.setExtraField("qcba022", "PASS");
+        mainData.setExtraField("qcba022", "1");
         mainData.setExtraField("qcba023", "75");
         mainData.setExtraField("qcba024", "inspector-zhang");
         mainData.setExtraField("qcba027", "5");
@@ -132,8 +134,16 @@ class Aqct300FormPushHandlerTest {
         qualityManagers.put("employeeField_mszdqrl8", "quality-first-001");
         qualityManagers.put("employeeField_mszdqrl9", "quality-second-001");
         when(documentService.queryDepartmentManagersByDepartmentId("962219816")).thenReturn(qualityManagers);
+
+        EmployeeRosterService roster = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo inspector = new EmployeeRosterInfo();
+        inspector.setEmployeeNo("inspector-zhang");
+        inspector.setUsername("张三");
+        inspector.setDingtalkId("inspector-user-id");
+        when(roster.findByEmployeeNo("inspector-zhang")).thenReturn(inspector);
+
         Aqct300FormPushHandler handler = new Aqct300FormPushHandler(yidaClient, ddConf, applications,
-                new LocalFormDepartmentEnricher(documentService));
+                new LocalFormDepartmentEnricher(documentService), (Aqct300ReferenceLookup) null, roster);
 
         assertEquals(Collections.singletonList("aqct300"), handler.getTypes());
         assertEquals("process-instance-1", handler.push(request));
@@ -155,30 +165,33 @@ class Aqct300FormPushHandlerTest {
         assertEquals("100", formData.getString("numberField_mt87k1g1"));
         assertEquals("PCS", formData.getString("textField_mt87k1g2"));
         assertEquals("item-name", formData.getString("textField_mt82p08x"));
+        assertEquals("ITEM-001", formData.getString("textField_mvyzi1jb"));
         assertEquals("item-spec", formData.getString("textField_mt80nq6v"));
         assertEquals("feature-a", formData.getString("textField_mt82p092"));
         assertEquals("EA", formData.getString("textField_mt82p091"));
         assertEquals("80", formData.getString("numberField_mt87k1ga"));
-        assertEquals("PASS", formData.getString("textField_mt82p094"));
+        assertEquals("合格", formData.getString("textField_mt82p094"));
         assertEquals("75", formData.getString("numberField_mt87k1gc"));
-        assertEquals("inspector-zhang", formData.getString("textField_mt87k1g4"));
+        assertEquals("张三", formData.getString("textField_mt87k1g4"));
+        assertEquals("inspector-user-id", formData.getJSONArray("employeeField_mt87k1g9").getString(0));
         assertEquals("LOT-001", formData.getString("textField_mt87k1gb"));
         assertEquals("Configured Company", formData.getString("selectField_mtxpfhhe"));
         assertEquals(Collections.singletonList("configured-dept-001"), formData.getJSONArray("departmentSelectField_msh186ry"));
         assertEquals(Collections.singletonList("quality-second-001"), formData.getJSONArray("employeeField_mvlu7ko7"));
         assertEquals("dingtalk-user-001", formData.getJSONArray("employeeField_msh186rr").getString(0));
-        // 缺口字段按约定丢弃:交易对象、料件、表头不良数
+        // 缺口字段按约定丢弃:交易对象、表头不良数
         assertNull(formData.get("qcba005"));
-        assertNull(formData.get("qcba010"));
         assertNull(formData.get("qcba027"));
 
         JSONArray table1 = formData.getJSONArray("tableField_psd31ws6n");
         assertEquals(1, table1.size());
         assertEquals("DIM", table1.getJSONObject(0).getString("textField_psd45i4rr"));
+        assertNull(table1.getJSONObject(0).get("textField_psd46kfhw"));
         assertEquals("20", table1.getJSONObject(0).getString("numberField_psd48zc2c"));
 
         JSONArray table2 = formData.getJSONArray("tableField_psd4cepjb");
         assertEquals("scratch", table2.getJSONObject(0).getString("textField_psd4ge7ws"));
+        assertNull(table2.getJSONObject(0).get("textField_psd4hbe3f"));
 
         JSONArray table3 = formData.getJSONArray("tableField_psd5lhkd9");
         assertEquals("10.1", table3.getJSONObject(0).getString("numberField_psd5reahw"));
@@ -211,4 +224,79 @@ class Aqct300FormPushHandlerTest {
                 new LocalFormDepartmentEnricher(mock(MingleiDocumentService.class)));
         assertThrows(IllegalArgumentException.class, () -> handler.push(request));
     }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushResolvesInspectionItemAndDefectReasonNames() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-2");
+        when(yidaClient.startProcess(anyMap())).thenReturn(response);
+
+        DDConf ddConf = new DDConf();
+        ddConf.setAppKey("app-key");
+        ddConf.setAppSecret("app-secret");
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
+        applications.getCoordinationOffice().setSystemToken("system-token");
+
+        ErpYidaPushMainData mainData = new ErpYidaPushMainData();
+        mainData.setTjr("3075");
+        mainData.setExtraField("qcbaent", "87");
+        mainData.setExtraField("qcbadocno", "QC-20260921-001");
+
+        Map<String, Object> detail1 = new LinkedHashMap<String, Object>();
+        detail1.put("qcbddocno", "QC-20260921-001");
+        detail1.put("qcbdseq", "10");
+        detail1.put("qcbd001", "DIM");
+
+        Map<String, Object> detail2 = new LinkedHashMap<String, Object>();
+        detail2.put("qcbe001", "scratch");
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(detail1));
+        detailData.put("detailData2", Collections.singletonList(detail2));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(Aqct300FormPushHandler.TYPE);
+        request.setOriginatorUserId("dingtalk-user-001");
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+
+        Aqct300ReferenceLookup lookup = new Aqct300ReferenceLookup() {
+            @Override
+            public String classificationName(String enterprise, String category, String code) {
+                if ("87".equals(enterprise) && "1051".equals(category) && "DIM".equals(code)) {
+                    return "尺寸";
+                }
+                if ("87".equals(enterprise) && "1053".equals(category) && "scratch".equals(code)) {
+                    return "划伤";
+                }
+                return null;
+            }
+
+            @Override
+            public String inspectionStandard(String enterprise, String docNo, String seq) {
+                if ("87".equals(enterprise) && "QC-20260921-001".equals(docNo) && "10".equals(seq)) {
+                    return "AQL-1";
+                }
+                return null;
+            }
+        };
+
+        Aqct300FormPushHandler handler = new Aqct300FormPushHandler(yidaClient, ddConf, applications,
+                new LocalFormDepartmentEnricher(FormDepartmentTestSupport.configuredDocumentService("dingtalk-user-001")),
+                lookup);
+        assertEquals("process-instance-2", handler.push(request));
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(yidaClient).startProcess(bodyCaptor.capture());
+        JSONObject formData = JSONObject.parseObject(String.valueOf(bodyCaptor.getValue().get("formDataJson")));
+        JSONObject table1 = formData.getJSONArray("tableField_psd31ws6n").getJSONObject(0);
+        assertEquals("尺寸", table1.getString("textField_psd45i4rr"));
+        assertEquals("AQL-1", table1.getString("textField_psd46kfhw"));
+        JSONObject table2 = formData.getJSONArray("tableField_psd4cepjb").getJSONObject(0);
+        assertEquals("划伤", table2.getString("textField_psd4ge7ws"));
+        assertEquals("划伤", table2.getString("textField_psd4hbe3f"));
+    }
 }

+ 64 - 0
src/test/java/com/malk/minglei/service/impl/yida/Aqct300OracleReferenceLookupTest.java

@@ -0,0 +1,64 @@
+package com.malk.minglei.service.impl.yida;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 检验项目、缺点原因和检验标准的 T100 查询参数。
+ */
+class Aqct300OracleReferenceLookupTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void classificationNameQueriesChineseDescriptionByCategory() {
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        when(jdbcTemplate.query(anyString(), any(SqlParameterSource.class), any(RowMapper.class)))
+                .thenReturn(Collections.singletonList("尺寸"));
+
+        Aqct300OracleReferenceLookup lookup = new Aqct300OracleReferenceLookup(jdbcTemplate);
+
+        assertEquals("尺寸", lookup.classificationName("87", "1051", "DIM"));
+        verify(jdbcTemplate).query(contains("DSDATA.oocq_t"), any(SqlParameterSource.class), any(RowMapper.class));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void inspectionStandardReadsQcbd002AndNormalizesDecimalSeq() {
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        when(jdbcTemplate.query(anyString(), any(SqlParameterSource.class), any(RowMapper.class)))
+                .thenAnswer(invocation -> {
+                    SqlParameterSource parameters = invocation.getArgument(1);
+                    assertEquals("87", parameters.getValue("ent"));
+                    assertEquals("QC-001", parameters.getValue("docNo"));
+                    assertEquals("10", parameters.getValue("seq"));
+                    return Collections.singletonList("AQL-1");
+                });
+
+        Aqct300OracleReferenceLookup lookup = new Aqct300OracleReferenceLookup(jdbcTemplate);
+
+        assertEquals("AQL-1", lookup.inspectionStandard("87", "QC-001", "10.0"));
+        verify(jdbcTemplate).query(contains("DSDATA.qcbd_t"), any(SqlParameterSource.class), any(RowMapper.class));
+    }
+
+    @Test
+    void blankCodeSkipsQuery() {
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        Aqct300OracleReferenceLookup lookup = new Aqct300OracleReferenceLookup(jdbcTemplate);
+
+        assertNull(lookup.classificationName("87", "1053", " "));
+        assertNull(lookup.inspectionStandard("87", "QC-001", null));
+    }
+}

+ 3 - 2
src/test/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandlerTest.java

@@ -53,7 +53,7 @@ class MiscReceiptIssueFormPushHandlerTest {
 
         Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
         detailRow.put("inbbent", "87");
-        detailRow.put("inbbdocno", "MS-001");
+        detailRow.put("inbbdocno", "miscReceiptIssue-MLZQ1261102010");
         detailRow.put("inbbseq", "10");
         detailRow.put("inbbua001", "SO-001");
         detailRow.put("inbbua002", "20");
@@ -102,7 +102,7 @@ class MiscReceiptIssueFormPushHandlerTest {
         assertEquals("miscReceiptIssue-MS-001", body.get("dataTitle"));
 
         JSONObject formData = JSONObject.parseObject(String.valueOf(body.get("formDataJson")));
-        assertEquals("miscReceiptIssue-MS-001", formData.getString("textField_msh27unu"));
+        assertEquals("reason-desc-mainMS-001", formData.getString("textField_msh27unu"));
         assertEquals("miscReceiptIssue-MS-001", formData.getString("textField_mspcpz92"));
         assertEquals("41603502201288023", formData.getJSONArray("employeeField_msh186rr").getString(0));
         assertEquals("Configured Company", formData.getString("selectField_mso1em48"));
@@ -118,6 +118,7 @@ class MiscReceiptIssueFormPushHandlerTest {
         assertEquals("reason-desc-main", formData.getString("textField_mt80nq6v"));
         assertEquals("remark-001", formData.getString("textField_mt7zi4fz"));
         assertEquals("MS-001", formData.getString("textField_mvc41oom"));
+        assertEquals("ZQ1", formData.getString("selectField_mvyv7ofx"));
 
         JSONArray detailRows = formData.getJSONArray("tableField_msh186sv");
         assertEquals(1, detailRows.size());