admin hace 2 días
padre
commit
be33582068

+ 98 - 1
README.md

@@ -364,6 +364,7 @@ Content-Type: application/json
 | `axmt140` | `CustomerReleaseFormPushHandler` | 客户放行审批 | `FORM-3BA9FE18494F4FDD85FE6ABF7DE741BAE30C` | `TPROC--6TG66381N8G8Y7L9GBEEY7QVXTUY2LBBGZ7TMU` |
 | `cint301` | `MiscReceiptIssueFormPushHandler` | 杂收杂发审批 | `FORM-4B08ADABCDAA45788C043729A855E03ED76G` | `TPROC--MQD66371C1N84FPSJZO4W79IN4BW2AE7I08TM0` |
 | `asft800` | `Asft800FormPushHandler` | 包装工单变更审批流程 | `FORM-40D6FBBBF4A94864A2AF9535AAC7924CRBH2` | `TPROC--U2I66F719HF854X6PIGK5A2AP9VC2LODB7YSM1` |
+| `aqct300` | `Aqct300FormPushHandler` | 检验情况审批流程 | `FORM-38C8592E7F5F45B3A388DA3FEFBA67C2GCEV` | `TPROC--LX766PC193N85EWUJ9ZLACNJNPSW3HBRG78TM1` |
 | `apmt100` | `Apmt100FormPushHandler` | 供应商导入流程 | `FORM-9EE14CA14D8D4AEE8102944E669E6B0CFUKS` | `TPROC--75G66PD15YG82766OFSWD8BS4ZJ33YPRFOZSM2` |
 
 ### 5.2 `mainData` 规则
@@ -399,6 +400,7 @@ Content-Type: application/json
 | `axmt140` | 优先 `detailData1`,缺失时回退到 `detailData` |
 | `cint301` | `detailData1` |
 | `asft800` | `detailData1` |
+| `aqct300` | `detailData1`~`detailData4` |
 | `apmt100` | 无明细,传入空对象 `{}` |
 
 明细对象中可以直接传入宜搭 `fieldId`。服务端会保留包含 `Field_` 的字段值,用于补充默认字段映射。
@@ -469,7 +471,102 @@ Content-Type: application/json
 
 `tjr` 是员工工号,服务端会从员工花名册解析对应钉钉用户作为发起人;未传时,处理器使用 `mainData.ooagud004` 作为钉钉用户 ID。`bm` 为钉钉部门 ID,`sfka902` 支持 `yyyy-MM-dd` 或 `yyyy/MM/dd` 格式。
 
-### 5.6 `apmt100` 供应商导入流程
+### 5.6 `aqct300` 检验情况审批流程
+
+调用 `POST /api/erp/yida/push` 时传入 `type: "aqct300"`。表头使用 ERP 原始字段;明细分别放入 `detailData1`(检验项目)、`detailData2`(缺点原因)、`detailData3`(计量检验)、`detailData4`(来源明细)。
+
+当前仅发起宜搭流程,不做 ERP 回写。以下字段宜搭无对应控件,推送时丢弃:`qcba005`(交易对象编号)、`qcba010`(料件编号)、`qcba027`(不良数)、`detailData3.qcbd002`(检验位置)。`qcba000` 需直接传宜搭选项值:`IQC` / `FQC` / `Inventory QC`。
+
+```json
+{
+  "type": "aqct300",
+  "bm": "123456789",
+  "gs": "87",
+  "tjr": "EMP0001",
+  "mainData": {
+    "rq": "2026-09-21",
+    "bm": "123456789",
+    "gs": "87",
+    "tjr": "EMP0001",
+    "qcbaent": "87",
+    "qcbadocno": "QC-20260921-001",
+    "qcbadocdt": "2026/09/21",
+    "qcba000": "IQC",
+    "qcba008": 100,
+    "qcba009": "PCS",
+    "imaal003": "料件品名",
+    "imaal004": "规格",
+    "qcba012": "产品特征",
+    "qcba016": "EA",
+    "qcba017": 80,
+    "qcba022": "PASS",
+    "qcba023": 75,
+    "qcba024": "张三",
+    "qcba034": "LOT-001"
+  },
+  "detailData": {
+    "detailData1": [
+      {
+        "qcbdent": "87",
+        "qcbddocno": "QC-20260921-001",
+        "qcbdseq": "10",
+        "qcbd001": "DIM",
+        "oocql004": "尺寸检验",
+        "qcbd009": 20,
+        "qcbd010": 1,
+        "qcbd011": "NG",
+        "qcbd021": 1
+      }
+    ],
+    "detailData2": [
+      {
+        "qcbeent": "87",
+        "qcbedocno": "QC-20260921-001",
+        "qcbeseq": "10",
+        "qcbe001": "划伤",
+        "qcbe002": 2,
+        "qcbe003": "备注",
+        "qcbe004": 2
+      }
+    ],
+    "detailData3": [
+      {
+        "qcbdent": "87",
+        "qcbddocno": "QC-20260921-001",
+        "qcbdseq": "10",
+        "qcbd013": 10.5,
+        "qcbd015": 9.5,
+        "qcbg002": 10.1,
+        "qcbd017": "mm",
+        "qcbg003": "Y",
+        "qcbd001": "SIZE",
+        "oocql004": "检验标准",
+        "qcbd019": "备注"
+      }
+    ],
+    "detailData4": [
+      {
+        "qcbaucseq": "1",
+        "qcbauc014": "PO",
+        "qcbauc001": "PO-001",
+        "qcbauc002": "10",
+        "qcbauc003": "REF-001",
+        "qcbauc004": "20",
+        "qcbauc005": "SUP-001",
+        "qcbauc008": 100,
+        "qcbauc009": "PCS",
+        "qcbauc010": "ITEM-001",
+        "imaal003": "料件品名",
+        "imaal004": "规格"
+      }
+    ]
+  }
+}
+```
+
+`tjr` 为员工工号,服务端从花名册解析钉钉用户作为发起人与申请人;`bm` 为钉钉部门 ID;`qcbadocdt` / `rq` 支持 `yyyy-MM-dd` 或 `yyyy/MM/dd`。
+
+### 5.7 `apmt100` 供应商导入流程
 
 调用 `POST /api/erp/yida/push` 时传入 `type: "apmt100"`。该流程无明细,`detailData` 必须为 `{}`。
 

+ 75 - 0
SKILL.md

@@ -0,0 +1,75 @@
+---
+name: java-development-guidelines
+description: 用户个人 Java 开发规范。用于用户要求遵守“我的 Java 开发规范”“java开发规范”“Java开发”“Java”,或为该用户编写、评审、重构 Java/Spring Boot 代码时。
+license: MIT
+---
+
+# 我的 Java 开发规范
+
+当用户要求“遵守我的 Java 开发规范”“按我的 java 开发规范来”“使用 Java 开发规范”,或当前任务是在此用户项目中编写、评审、重构 Java / Spring Boot 代码时,使用本技能。
+
+本规范的目标是:最小范围完成需求、保持 Java 工程结构清晰、让变更可验证,并沉淀项目内可复用经验。
+
+## 执行前
+
+- 修改前先说明目标、关键假设和可验证的完成标准。
+- 若需求有多种合理解释,先列出差异;只有影响实现正确性时才提问。
+- 优先读取现有代码、配置、README 和测试,遵守当前项目的包结构、命名、异常处理、返回体、日志和依赖管理方式。
+- 不读取、复制或输出 `.env`、Token、Cookie、认证文件、日志、缓存等敏感数据,除非用户明确授权且任务确有必要。
+
+## 实现原则
+
+- 只实现用户请求的功能,不做未请求的重构、抽象或扩展。
+- 修改范围保持可追溯:每一处变更都应能对应到用户需求、编译修复、测试修复或由本次变更引入的清理。
+- 不顺手格式化无关文件,不删除无关死代码;如发现无关问题,在回复中说明。
+- 优先使用项目已有工具类、基础响应类型、异常体系、校验方式和测试框架。
+- 业务逻辑应清晰直接。只有当重复、复杂度或既有项目模式需要时,才新增抽象。
+
+## Java 代码规范
+
+- 类名使用 `UpperCamelCase`,方法、变量和字段使用 `lowerCamelCase`,常量使用 `UPPER_SNAKE_CASE`。
+- 新增公共 API、DTO、枚举、异常和配置项时,命名必须表达业务含义,避免 `Info`、`Data`、`Util` 等含糊命名,除非项目已有稳定约定。
+- 优先使用构造器注入或项目既有注入方式;避免在业务代码中手动 `new` Spring 管理的依赖。
+- 输入校验放在边界层或明确的业务校验位置,错误信息应可定位问题。
+- 集合、Optional、Stream、Lambda 的使用以可读性为准,不为“函数式”牺牲调试和理解成本。
+- 金额、时间、精度敏感数据使用合适类型;避免用 `double/float` 处理金额。
+- 不吞异常,不打印后继续伪装成功;异常要么转成项目统一异常,要么保留上下文继续抛出。
+- 日志记录关键业务上下文,不记录敏感数据,不用 `System.out.println` 替代日志框架。
+
+## 注释要求
+
+- 所有新增或实质修改的类、接口和方法,都要在声明前添加多行注释或 Javadoc,说明职责;方法还应在适用时说明重要输入、输出、副作用和失败行为。
+- 重要实现块或非显而易见的决策前添加单行注释,例如业务规则、复杂条件、状态流转、外部调用、数据转换、事务边界。
+- 注释解释“为什么”和业务意图,不逐字复述代码。
+- 新增或修改的注释使用简体中文。类名、方法名、参数名、类型等标识符可保持英文;`@param`、`@return`、`@throws` 后的说明使用简体中文。
+- 不批量改写第三方代码、生成文件或本次变更范围外的既有注释。
+
+## Spring Boot 分层规范
+
+Spring Boot 项目默认使用 `Controller` / `Service` / `ServiceImpl` 三层结构:
+
+```text
+controller/      # Controller 类,只处理 HTTP 入参、鉴权上下文、响应封装等边界逻辑
+service/         # Service 接口,定义业务能力
+service/impl/    # Service 实现类,承载业务逻辑
+```
+
+- Controller 不写核心业务逻辑,只调用 Service 接口。
+- ServiceImpl 实现业务流程、校验、事务编排和外部集成协调。
+- 持久化、实体、DTO、VO、配置、常量、通用响应、工具类、外部客户端等放入项目已有或命名清晰的辅助包。
+- 需要参考工程约定时,可把 `C:\Users\EDY\Desktop\work\模板\IntegrateDingTalkOA` 作为风格参考,但不要盲目复制与当前项目无关的结构。
+
+## 测试与验证
+
+- 修复 bug 时,优先补充能复现问题的测试,再修复并让测试通过。
+- 新增业务逻辑时,按项目现有测试层级补充单元测试或集成测试;若项目没有测试基础,至少运行可用的编译、静态检查或启动前校验。
+- 修改完成后运行与变更范围匹配的验证命令,例如 `mvn test`、`mvn -q test`、`mvn package`、`gradle test`、`gradle build`,并在最终回复中说明结果。
+- 若验证无法运行,说明原因、已做的替代检查和剩余风险。
+
+## README 沉淀
+
+- 修改前读取项目已有 `README.md`,保留结构和语言风格。
+- 修改代码时同步检查本次修改范围内的 `README.md`。若变更影响接口路径、请求方法、请求体、响应体、状态码、字段含义、调用示例或配置项,必须同步更新对应文档。
+- 当开发中发现可复用的项目约定、踩坑规避方式或后续必须遵守的实践时,在完成前更新 README 的相关章节。
+- 记录触发条件、必须遵守的做法,以及必要时的原因或后果。
+- 不写入临时调试记录、普通进度、密钥、凭据或敏感信息。

+ 506 - 0
src/main/java/com/malk/minglei/service/impl/yida/Aqct300FormPushHandler.java

@@ -0,0 +1,506 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.ErpYidaPushMainData;
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.yida.YidaFormPushHandler;
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.dingtalk.DDConf;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 检验情况审批流程(aqct300)ERP 推送到宜搭的处理器。
+ * <p>
+ * 仅负责发起流程;交易对象编号、料件编号、表头不良数、检验位置等缺口字段按约定丢弃。
+ */
+@Service
+public class Aqct300FormPushHandler implements YidaFormPushHandler {
+
+    public static final String TYPE = "aqct300";
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(Aqct300FormPushHandler.class);
+    private static final String START_PROCESS_ENDPOINT = "/v1.0/yida/processes/instances/start";
+    private static final String FORM_NAME = "\u68c0\u9a8c\u60c5\u51b5\u5ba1\u6279\u6d41\u7a0b";
+    private static final String FORM_UUID = "FORM-38C8592E7F5F45B3A388DA3FEFBA67C2GCEV";
+    private static final String PROCESS_CODE = "TPROC--LX766PC193N85EWUJ9ZLACNJNPSW3HBRG78TM1";
+
+    private static final String TITLE_FIELD_ID = "textField_msh27unu";
+    private static final String APPLICANT_FIELD_ID = "employeeField_msh186rr";
+    private static final String COMPANY_FIELD_ID = "selectField_mtxpfhhe";
+    private static final String DEPARTMENT_FIELD_ID = "departmentSelectField_msh186ry";
+    private static final String APPLY_DATE_FIELD_ID = "dateField_msh186rs";
+    private static final String FORM_TITLE_FIELD_ID = "textField_mspcpz92";
+
+    private static final String ENTERPRISE_FIELD_ID = "textField_mt82p09i";
+    private static final String DOC_NO_FIELD_ID = "textField_mt87k1g0";
+    private static final String INSPECTION_TYPE_FIELD_ID = "selectField_mt87k1g3";
+    private static final String DOC_DATE_FIELD_ID = "dateField_mt82p09f";
+    private static final String SOURCE_QTY_FIELD_ID = "numberField_mt87k1g1";
+    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";
+    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";
+    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 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";
+    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";
+    private static final String T1_ITEM_RESULT_FIELD_ID = "textField_psd4aboib";
+    private static final String T1_BAD_QTY_FIELD_ID = "numberField_psd4bkuxd";
+
+    private static final String TABLE2_FIELD_ID = "tableField_psd4cepjb";
+    private static final String T2_ENT_FIELD_ID = "textField_psd4d0x9h";
+    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";
+    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";
+
+    private static final String TABLE3_FIELD_ID = "tableField_psd5lhkd9";
+    private static final String T3_ENT_FIELD_ID = "textField_psd5mj42d";
+    private static final String T3_DOC_NO_FIELD_ID = "textField_psd5nzcrr";
+    private static final String T3_SEQ_FIELD_ID = "textField_psd5o26sl";
+    private static final String T3_UPPER_FIELD_ID = "numberField_psd5pwsp0";
+    private static final String T3_LOWER_FIELD_ID = "numberField_psd5q5ti2";
+    private static final String T3_MEASURE_FIELD_ID = "numberField_psd5reahw";
+    private static final String T3_UNIT_FIELD_ID = "textField_psd5sxmwd";
+    private static final String T3_PASS_FLAG_FIELD_ID = "textField_psd5tpt5q";
+    private static final String T3_ITEM_FIELD_ID = "textField_psd5vxs6b";
+    private static final String T3_STANDARD_FIELD_ID = "textField_psd5wy2x0";
+    private static final String T3_REMARK_FIELD_ID = "textField_psd5xt4zr";
+
+    private static final String TABLE4_FIELD_ID = "tableField_psd5yvj22";
+    private static final String T4_SEQ_FIELD_ID = "textField_psd5z49u7";
+    private static final String T4_SOURCE_TYPE_FIELD_ID = "textField_psd510o7sr";
+    private static final String T4_SOURCE_DOC_FIELD_ID = "textField_psd511xgy1";
+    private static final String T4_SOURCE_SEQ_FIELD_ID = "textField_psd5127rvd";
+    private static final String T4_REF_DOC_FIELD_ID = "textField_psd513ufnr";
+    private static final String T4_REF_SEQ_FIELD_ID = "textField_psd514yvch";
+    private static final String T4_SUPPLIER_FIELD_ID = "textField_psd515qf23";
+    private static final String T4_SOURCE_QTY_FIELD_ID = "numberField_psd516nsm3";
+    private static final String T4_UNIT_FIELD_ID = "textField_psd517x778";
+    private static final String T4_ITEM_CODE_FIELD_ID = "textField_psd518e40f";
+    private static final String T4_ITEM_NAME_FIELD_ID = "textField_psd519lkbj";
+    private static final String T4_SPEC_FIELD_ID = "textField_psd51ao5h9";
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties applications;
+
+    /**
+     * 创建检验情况审批推送处理器。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     */
+    public Aqct300FormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                  DDConf dingTalkConfiguration,
+                                  YidaApplicationsProperties applications) {
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.applications = applications;
+    }
+
+    /**
+     * 返回检验情况审批支持的 ERP 推送类型。
+     *
+     * @return 支持的 type 列表
+     */
+    @Override
+    public List<String> getTypes() {
+        return Collections.singletonList(TYPE);
+    }
+
+    /**
+     * 将检验情况单据转换为宜搭流程表单并发起流程。
+     *
+     * @param request ERP 推送请求,需已由上层解析 {@code mainData.tjr} 为发起人
+     * @return 宜搭流程实例 ID 或成功标识
+     */
+    @Override
+    public String push(ErpYidaPushRequest request) {
+        validateConfiguration();
+
+        Map<String, Object> mainData = toMainDataMap(request.getMainData());
+        String operatorUserId = requireText(resolveOperatorUserId(request), "aqct300 requires mainData.tjr");
+        Map<String, Object> formData = toYidaFormData(request, mainData, operatorUserId);
+        String title = requireText(toText(formData.get(TITLE_FIELD_ID)), "aqct300 requires mainData.qcbadocno");
+
+        Map<String, Object> body = new LinkedHashMap<String, Object>();
+        body.put("appType", requireText(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured"));
+        body.put("systemToken", requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured"));
+        body.put("userId", operatorUserId);
+        body.put("processCode", PROCESS_CODE);
+        body.put("formUuid", FORM_UUID);
+        body.put("formDataJson", JSON.toJSONString(formData));
+        body.put("executeExpression", true);
+        body.put("noExecuteExpression", false);
+        body.put("title", title);
+        body.put("dataTitle", title);
+
+        LOGGER.info("Yida process start request endpoint={} formName={} body={}",
+                START_PROCESS_ENDPOINT, FORM_NAME, JSON.toJSONString(maskSensitive(body)));
+        try {
+            return extractProcessInstanceId(yidaProcessOpenApiClient.startProcess(body));
+        } catch (McException exception) {
+            throw exception;
+        } catch (Exception exception) {
+            LOGGER.error("Yida process start failed formName={} body={}",
+                    FORM_NAME, JSON.toJSONString(maskSensitive(body)), exception);
+            throw new McException("DINGTALK_YIDA_ERROR", "Yida process start failed: " + rootMessage(exception));
+        }
+    }
+
+    /**
+     * 将 ERP 主表与四张明细映射为宜搭 formData。
+     */
+    private Map<String, Object> toYidaFormData(ErpYidaPushRequest request,
+                                               Map<String, Object> mainData,
+                                               String operatorUserId) {
+        String docNo = toText(mainData.get("qcbadocno"));
+        String title = docNo == null ? null : FORM_NAME + "-" + docNo;
+        Object company = toCompanyName(firstNonBlank(mainData.get("gs"), mainData.get("qcbaent"), mainData.get("ent")));
+
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        putIfNotBlank(formData, TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, APPLICANT_FIELD_ID, Collections.singletonList(operatorUserId));
+        putIfNotBlank(formData, COMPANY_FIELD_ID, company);
+        putIfNotBlank(formData, DEPARTMENT_FIELD_ID, toStringList(mainData.get("bm")));
+        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(firstNonBlank(mainData.get("rq"), mainData.get("qcbadocdt"))));
+        putIfNotBlank(formData, FORM_TITLE_FIELD_ID, title);
+
+        putIfNotBlank(formData, ENTERPRISE_FIELD_ID, mainData.get("qcbaent"));
+        putIfNotBlank(formData, DOC_NO_FIELD_ID, docNo);
+        putIfNotBlank(formData, INSPECTION_TYPE_FIELD_ID, 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, 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, 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")));
+
+        Map<String, Object> detailWrapper = objectToMap(request.getDetailData());
+        List<Map<String, Object>> table1 = toTable1Rows(toMapRows(detailWrapper.get("detailData1")));
+        if (!table1.isEmpty()) {
+            formData.put(TABLE1_FIELD_ID, table1);
+        }
+        List<Map<String, Object>> table2 = toTable2Rows(toMapRows(detailWrapper.get("detailData2")));
+        if (!table2.isEmpty()) {
+            formData.put(TABLE2_FIELD_ID, table2);
+        }
+        List<Map<String, Object>> table3 = toTable3Rows(toMapRows(detailWrapper.get("detailData3")));
+        if (!table3.isEmpty()) {
+            formData.put(TABLE3_FIELD_ID, table3);
+        }
+        List<Map<String, Object>> table4 = toTable4Rows(toMapRows(detailWrapper.get("detailData4")));
+        if (!table4.isEmpty()) {
+            formData.put(TABLE4_FIELD_ID, table4);
+        }
+        return formData;
+    }
+
+    /**
+     * 映射子表1(检验项目判定)。
+     */
+    private List<Map<String, Object>> toTable1Rows(List<Map<String, Object>> sourceRows) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Map<String, Object> source : sourceRows) {
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            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_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")));
+            putIfNotBlank(row, T1_ITEM_RESULT_FIELD_ID, source.get("qcbd011"));
+            putIfNotBlank(row, T1_BAD_QTY_FIELD_ID, toDecimal(source.get("qcbd021")));
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    /**
+     * 映射子表2(缺点原因)。
+     */
+    private List<Map<String, Object>> toTable2Rows(List<Map<String, Object>> sourceRows) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Map<String, Object> source : sourceRows) {
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            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_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")));
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    /**
+     * 映射子表3(计量检验);检验位置 {@code qcbd002} 无宜搭字段,丢弃。
+     */
+    private List<Map<String, Object>> toTable3Rows(List<Map<String, Object>> sourceRows) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Map<String, Object> source : sourceRows) {
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfNotBlank(row, T3_ENT_FIELD_ID, source.get("qcbdent"));
+            putIfNotBlank(row, T3_DOC_NO_FIELD_ID, source.get("qcbddocno"));
+            putIfNotBlank(row, T3_SEQ_FIELD_ID, source.get("qcbdseq"));
+            putIfNotBlank(row, T3_UPPER_FIELD_ID, toDecimal(source.get("qcbd013")));
+            putIfNotBlank(row, T3_LOWER_FIELD_ID, toDecimal(source.get("qcbd015")));
+            putIfNotBlank(row, T3_MEASURE_FIELD_ID, toDecimal(source.get("qcbg002")));
+            putIfNotBlank(row, T3_UNIT_FIELD_ID, source.get("qcbd017"));
+            putIfNotBlank(row, T3_PASS_FLAG_FIELD_ID, source.get("qcbg003"));
+            putIfNotBlank(row, T3_ITEM_FIELD_ID, source.get("qcbd001"));
+            putIfNotBlank(row, T3_STANDARD_FIELD_ID, source.get("oocql004"));
+            putIfNotBlank(row, T3_REMARK_FIELD_ID, source.get("qcbd019"));
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    /**
+     * 映射子表4(来源明细)。
+     */
+    private List<Map<String, Object>> toTable4Rows(List<Map<String, Object>> sourceRows) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Map<String, Object> source : sourceRows) {
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfNotBlank(row, T4_SEQ_FIELD_ID, source.get("qcbaucseq"));
+            putIfNotBlank(row, T4_SOURCE_TYPE_FIELD_ID, source.get("qcbauc014"));
+            putIfNotBlank(row, T4_SOURCE_DOC_FIELD_ID, source.get("qcbauc001"));
+            putIfNotBlank(row, T4_SOURCE_SEQ_FIELD_ID, source.get("qcbauc002"));
+            putIfNotBlank(row, T4_REF_DOC_FIELD_ID, source.get("qcbauc003"));
+            putIfNotBlank(row, T4_REF_SEQ_FIELD_ID, source.get("qcbauc004"));
+            putIfNotBlank(row, T4_SUPPLIER_FIELD_ID, source.get("qcbauc005"));
+            putIfNotBlank(row, T4_SOURCE_QTY_FIELD_ID, toDecimal(source.get("qcbauc008")));
+            putIfNotBlank(row, T4_UNIT_FIELD_ID, source.get("qcbauc009"));
+            putIfNotBlank(row, T4_ITEM_CODE_FIELD_ID, source.get("qcbauc010"));
+            putIfNotBlank(row, T4_ITEM_NAME_FIELD_ID, source.get("imaal003"));
+            putIfNotBlank(row, T4_SPEC_FIELD_ID, source.get("imaal004"));
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    /**
+     * 优先使用上层已解析的发起人钉钉 userId。
+     */
+    private String resolveOperatorUserId(ErpYidaPushRequest request) {
+        return toText(request.getOriginatorUserId());
+    }
+
+    private Map<String, Object> toMainDataMap(ErpYidaPushMainData mainData) {
+        if (mainData == null) {
+            return Collections.emptyMap();
+        }
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        putIfNotBlank(result, "rq", mainData.getRq());
+        putIfNotBlank(result, "bm", mainData.getBm());
+        putIfNotBlank(result, "tjr", mainData.getTjr());
+        putIfNotBlank(result, "gs", mainData.getGs());
+        putIfNotBlank(result, "t100id", mainData.getT100id());
+        putIfNotBlank(result, "ent", mainData.getEnt());
+        putIfNotBlank(result, "t100bm", mainData.getT100bm());
+        result.putAll(mainData.getExtraFields());
+        return result;
+    }
+
+    private Map<String, Object> objectToMap(Object value) {
+        if (value instanceof Map) {
+            Map<String, Object> result = new LinkedHashMap<String, Object>();
+            for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
+                if (entry.getKey() != null) {
+                    result.put(String.valueOf(entry.getKey()), entry.getValue());
+                }
+            }
+            return result;
+        }
+        return Collections.emptyMap();
+    }
+
+    private List<Map<String, Object>> toMapRows(Object value) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        if (!(value instanceof Iterable)) {
+            return rows;
+        }
+        for (Object item : (Iterable<?>) value) {
+            Map<String, Object> row = objectToMap(item);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private Object firstNonBlank(Object first, Object second) {
+        return isBlank(first) ? second : first;
+    }
+
+    private Object firstNonBlank(Object first, Object second, Object third) {
+        Object value = firstNonBlank(first, second);
+        return isBlank(value) ? third : value;
+    }
+
+    private List<String> toStringList(Object value) {
+        if (isBlank(value)) {
+            return Collections.emptyList();
+        }
+        if (value instanceof Iterable) {
+            List<String> values = new ArrayList<String>();
+            for (Object item : (Iterable<?>) value) {
+                if (!isBlank(item)) {
+                    values.add(String.valueOf(item).trim());
+                }
+            }
+            return values;
+        }
+        return Collections.singletonList(String.valueOf(value).trim());
+    }
+
+    /**
+     * 将 ERP 公司/企业编码转换为宜搭申请公司选项文案。
+     */
+    private String toCompanyName(Object value) {
+        String text = toText(value);
+        if (text == null) {
+            return null;
+        }
+        if ("87".equals(text) || "ML".equalsIgnoreCase(text) || "zh_CN".equalsIgnoreCase(text)) {
+            return "\u660e\u78ca";
+        }
+        if ("VN".equalsIgnoreCase(text) || "vi_VN".equalsIgnoreCase(text)) {
+            return "\u8d8a\u5357";
+        }
+        return text;
+    }
+
+    private Long toDateMillis(Object value) {
+        String text = toText(value);
+        if (text == null) {
+            return null;
+        }
+        if (text.matches("\\d+")) {
+            return Long.valueOf(text);
+        }
+        return LocalDate.parse(text.replace('/', '-')).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+    }
+
+    private BigDecimal toDecimal(Object value) {
+        String text = toText(value);
+        return text == null ? null : new BigDecimal(text);
+    }
+
+    private String toText(Object value) {
+        return isBlank(value) ? null : String.valueOf(value).trim();
+    }
+
+    private boolean isBlank(Object value) {
+        if (value == null) {
+            return true;
+        }
+        if (value instanceof Iterable) {
+            return !((Iterable<?>) value).iterator().hasNext();
+        }
+        if (value instanceof Map) {
+            return ((Map<?, ?>) value).isEmpty();
+        }
+        return String.valueOf(value).trim().isEmpty();
+    }
+
+    private String extractProcessInstanceId(DDR_New response) {
+        if (response == null) {
+            throw new McException("YIDA_NULL_RESPONSE", "Yida process start failed: empty response");
+        }
+        Object result = firstNonBlank(firstNonBlank(response.getResult(), response.getInstanceId()), response.getFormInstId());
+        if (!isBlank(result)) {
+            return String.valueOf(result);
+        }
+        if (response.isSuccess()) {
+            return "SUCCESS";
+        }
+        throw new McException(defaultText(response.getCode(), "YIDA_ERROR"),
+                "Yida process start failed: " + defaultText(response.getMessage(), "no process instance id"));
+    }
+
+    private Map<String, Object> maskSensitive(Map<String, Object> source) {
+        Map<String, Object> body = new LinkedHashMap<String, Object>(source);
+        body.put("systemToken", "******");
+        return body;
+    }
+
+    private String rootMessage(Throwable exception) {
+        Throwable current = exception;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        return defaultText(current.getMessage(), current.getClass().getSimpleName());
+    }
+
+    private String defaultText(String value, String fallback) {
+        return value == null || value.trim().isEmpty() ? fallback : value.trim();
+    }
+
+    private String requireText(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalArgumentException(message);
+        }
+        return value.trim();
+    }
+
+    private void putIfNotBlank(Map<String, Object> target, String key, Object value) {
+        if (!isBlank(value)) {
+            target.put(key, value);
+        }
+    }
+
+    private void validateConfiguration() {
+        requireText(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        requireText(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        requireText(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+}

BIN
src/main/java/com/malk/service/mjava/0.0.3/mjava-0.0.3.jar


+ 4 - 0
src/main/java/com/malk/service/mjava/0.0.3/mjava-0.0.3.jar.lastUpdated

@@ -0,0 +1,4 @@
+#NOTE: This is a Maven Resolver internal implementation file, its format can be changed without prior notice.
+#Thu Aug 20 08:57:26 GMT+08:00 2026
+https\://repo.maven.apache.org/maven2/.lastUpdated=1787187446121
+https\://repo.maven.apache.org/maven2/.error=

+ 55 - 0
src/main/java/com/malk/service/mjava/0.0.3/mjava-0.0.3.pom

@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>java-mcli</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+    <!-- mjava版本, 不同java-cli项目区分底层依赖, 使用变量有警告 -->
+    <version>0.0.3</version>
+
+    <artifactId>mjava</artifactId>
+    <description>mjava framework</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpclient</artifactId>
+            <version>4.5.14</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <!-- 如果没有该配置,devtools不会生效: 打包时关闭 -->
+                    <fork>false</fork>
+                    <!-- 避免中文乱码 -->
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <!-- 允许生成可运行jar: 发布作为基础包提供, 注释 executions 再执行 install 到本地 maven. 若开启即可作为独立 jar 运行 -->
+                <executions>
+                    <execution>
+                        <goals>
+                            <!--                            <goal>repackage</goal>-->
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 4 - 0
src/main/java/com/malk/service/mjava/0.0.3/mjava-0.0.3.pom.lastUpdated

@@ -0,0 +1,4 @@
+#NOTE: This is a Maven Resolver internal implementation file, its format can be changed without prior notice.
+#Thu Aug 20 08:56:27 GMT+08:00 2026
+https\://repo.maven.apache.org/maven2/.lastUpdated=1787187387140
+https\://repo.maven.apache.org/maven2/.error=

+ 12 - 0
src/main/java/com/malk/service/mjava/maven-metadata-local.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<metadata>
+  <groupId>com.malk</groupId>
+  <artifactId>mjava</artifactId>
+  <versioning>
+    <release>0.0.3</release>
+    <versions>
+      <version>0.0.3</version>
+    </versions>
+    <lastUpdated>20260819024505</lastUpdated>
+  </versioning>
+</metadata>

+ 203 - 0
src/test/java/com/malk/minglei/service/impl/yida/Aqct300FormPushHandlerTest.java

@@ -0,0 +1,203 @@
+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.ErpYidaPushMainData;
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.dingtalk.DDConf;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 检验情况审批(aqct300)字段映射与发起参数校验。
+ */
+class Aqct300FormPushHandlerTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushMapsAqct300PayloadToInspectionApprovalProcess() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-1");
+        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.setGs("87");
+        mainData.setBm("dept-1");
+        mainData.setTjr("3075");
+        mainData.setExtraField("qcbaent", "87");
+        mainData.setExtraField("qcbadocno", "QC-20260921-001");
+        mainData.setExtraField("qcbadocdt", "2026/09/21");
+        mainData.setExtraField("qcba000", "IQC");
+        mainData.setExtraField("qcba005", "SUP-001");
+        mainData.setExtraField("qcba008", "100");
+        mainData.setExtraField("qcba009", "PCS");
+        mainData.setExtraField("qcba010", "ITEM-001");
+        mainData.setExtraField("imaal003", "item-name");
+        mainData.setExtraField("imaal004", "item-spec");
+        mainData.setExtraField("qcba012", "feature-a");
+        mainData.setExtraField("qcba016", "EA");
+        mainData.setExtraField("qcba017", "80");
+        mainData.setExtraField("qcba022", "PASS");
+        mainData.setExtraField("qcba023", "75");
+        mainData.setExtraField("qcba024", "inspector-zhang");
+        mainData.setExtraField("qcba027", "5");
+        mainData.setExtraField("qcba034", "LOT-001");
+
+        Map<String, Object> detail1 = new LinkedHashMap<String, Object>();
+        detail1.put("qcbdent", "87");
+        detail1.put("qcbddocno", "QC-20260921-001");
+        detail1.put("qcbdseq", "10");
+        detail1.put("qcbd001", "DIM");
+        detail1.put("oocql004", "dimension check");
+        detail1.put("qcbd009", "20");
+        detail1.put("qcbd010", "1");
+        detail1.put("qcbd011", "NG");
+        detail1.put("qcbd021", "1");
+
+        Map<String, Object> detail2 = new LinkedHashMap<String, Object>();
+        detail2.put("qcbeent", "87");
+        detail2.put("qcbedocno", "QC-20260921-001");
+        detail2.put("qcbeseq", "10");
+        detail2.put("qcbe001", "scratch");
+        detail2.put("qcbe002", "2");
+        detail2.put("qcbe003", "remark-2");
+        detail2.put("qcbe004", "2");
+
+        Map<String, Object> detail3 = new LinkedHashMap<String, Object>();
+        detail3.put("qcbdent", "87");
+        detail3.put("qcbddocno", "QC-20260921-001");
+        detail3.put("qcbdseq", "10");
+        detail3.put("qcbd013", "10.5");
+        detail3.put("qcbd015", "9.5");
+        detail3.put("qcbg002", "10.1");
+        detail3.put("qcbd017", "mm");
+        detail3.put("qcbg003", "Y");
+        detail3.put("qcbd001", "SIZE");
+        detail3.put("oocql004", "standard-a");
+        detail3.put("qcbd002", "pos-1");
+        detail3.put("qcbd019", "remark-3");
+
+        Map<String, Object> detail4 = new LinkedHashMap<String, Object>();
+        detail4.put("qcbaucseq", "1");
+        detail4.put("qcbauc014", "PO");
+        detail4.put("qcbauc001", "PO-001");
+        detail4.put("qcbauc002", "10");
+        detail4.put("qcbauc003", "REF-001");
+        detail4.put("qcbauc004", "20");
+        detail4.put("qcbauc005", "SUP-001");
+        detail4.put("qcbauc008", "100");
+        detail4.put("qcbauc009", "PCS");
+        detail4.put("qcbauc010", "ITEM-001");
+        detail4.put("imaal003", "item-name");
+        detail4.put("imaal004", "item-spec");
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(detail1));
+        detailData.put("detailData2", Collections.singletonList(detail2));
+        detailData.put("detailData3", Collections.singletonList(detail3));
+        detailData.put("detailData4", Collections.singletonList(detail4));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(Aqct300FormPushHandler.TYPE);
+        request.setOriginatorUserId("dingtalk-user-001");
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+
+        Aqct300FormPushHandler handler = new Aqct300FormPushHandler(yidaClient, ddConf, applications);
+
+        assertEquals(Collections.singletonList("aqct300"), handler.getTypes());
+        assertEquals("process-instance-1", handler.push(request));
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(yidaClient).startProcess(bodyCaptor.capture());
+        Map body = bodyCaptor.getValue();
+        assertEquals("APP_RJJL69QUIZVQSV7YC8TL", body.get("appType"));
+        assertEquals("dingtalk-user-001", body.get("userId"));
+        assertEquals("FORM-38C8592E7F5F45B3A388DA3FEFBA67C2GCEV", body.get("formUuid"));
+        assertEquals("TPROC--LX766PC193N85EWUJ9ZLACNJNPSW3HBRG78TM1", body.get("processCode"));
+        assertEquals("\u68c0\u9a8c\u60c5\u51b5\u5ba1\u6279\u6d41\u7a0b-QC-20260921-001", body.get("title"));
+
+        JSONObject formData = JSONObject.parseObject(String.valueOf(body.get("formDataJson")));
+        assertEquals("87", formData.getString("textField_mt82p09i"));
+        assertEquals("QC-20260921-001", formData.getString("textField_mt87k1g0"));
+        assertEquals("IQC", formData.getString("selectField_mt87k1g3"));
+        assertEquals("100", formData.getString("numberField_mt87k1g1"));
+        assertEquals("PCS", formData.getString("textField_mt87k1g2"));
+        assertEquals("item-name", formData.getString("textField_mt82p08x"));
+        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("75", formData.getString("numberField_mt87k1gc"));
+        assertEquals("inspector-zhang", formData.getString("textField_mt87k1g4"));
+        assertEquals("LOT-001", formData.getString("textField_mt87k1gb"));
+        assertEquals("\u660e\u78ca", formData.getString("selectField_mtxpfhhe"));
+        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"));
+        assertEquals("20", table1.getJSONObject(0).getString("numberField_psd48zc2c"));
+
+        JSONArray table2 = formData.getJSONArray("tableField_psd4cepjb");
+        assertEquals("scratch", table2.getJSONObject(0).getString("textField_psd4ge7ws"));
+
+        JSONArray table3 = formData.getJSONArray("tableField_psd5lhkd9");
+        assertEquals("10.1", table3.getJSONObject(0).getString("numberField_psd5reahw"));
+        assertEquals("Y", table3.getJSONObject(0).getString("textField_psd5tpt5q"));
+        assertNull(table3.getJSONObject(0).get("qcbd002"));
+
+        JSONArray table4 = formData.getJSONArray("tableField_psd5yvj22");
+        assertEquals("PO-001", table4.getJSONObject(0).getString("textField_psd511xgy1"));
+        assertEquals("ITEM-001", table4.getJSONObject(0).getString("textField_psd518e40f"));
+        assertEquals("SUP-001", table4.getJSONObject(0).getString("textField_psd515qf23"));
+    }
+
+    @Test
+    void pushRequiresOriginatorUserId() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        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.setExtraField("qcbadocno", "QC-001");
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setMainData(mainData);
+        request.setDetailData(Collections.emptyMap());
+
+        Aqct300FormPushHandler handler = new Aqct300FormPushHandler(yidaClient, ddConf, applications);
+        assertThrows(IllegalArgumentException.class, () -> handler.push(request));
+    }
+}