Bladeren bron

feat: 新增 axmt510 订单变更申请流程 ERP 推送宜搭

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
admin 1 maand geleden
bovenliggende
commit
e4713e48c1

+ 103 - 1
README.md

@@ -365,6 +365,7 @@ Content-Type: application/json
 | `cint301` | `MiscReceiptIssueFormPushHandler` | 杂收杂发审批 | `FORM-4B08ADABCDAA45788C043729A855E03ED76G` | `TPROC--MQD66371C1N84FPSJZO4W79IN4BW2AE7I08TM0` |
 | `asft800` | `Asft800FormPushHandler` | 包装工单变更审批流程 | `FORM-40D6FBBBF4A94864A2AF9535AAC7924CRBH2` | `TPROC--U2I66F719HF854X6PIGK5A2AP9VC2LODB7YSM1` |
 | `aqct300` | `Aqct300FormPushHandler` | 检验情况审批流程 | `FORM-38C8592E7F5F45B3A388DA3FEFBA67C2GCEV` | `TPROC--LX766PC193N85EWUJ9ZLACNJNPSW3HBRG78TM1` |
+| `axmt510` | `Axmt510FormPushHandler` | 订单变更申请流程 | `FORM-8F318292B70D4ED39080EE0C631DC74BG3MD` | `TPROC--BHC66W71Q1N87Q0YFD7FVBNYXRJ62OIA378TM4` |
 | `apmt100` | `Apmt100FormPushHandler` | 供应商导入流程 | `FORM-9EE14CA14D8D4AEE8102944E669E6B0CFUKS` | `TPROC--75G66PD15YG82766OFSWD8BS4ZJ33YPRFOZSM2` |
 
 ### 5.2 `mainData` 规则
@@ -401,6 +402,7 @@ Content-Type: application/json
 | `cint301` | `detailData1` |
 | `asft800` | `detailData1` |
 | `aqct300` | `detailData1`~`detailData4` |
+| `axmt510` | `detailData1`~`detailData3` |
 | `apmt100` | 无明细,传入空对象 `{}` |
 
 明细对象中可以直接传入宜搭 `fieldId`。服务端会保留包含 `Field_` 的字段值,用于补充默认字段映射。
@@ -566,7 +568,107 @@ Content-Type: application/json
 
 `tjr` 为员工工号,服务端从花名册解析钉钉用户作为发起人与申请人;`bm` 为钉钉部门 ID;`qcbadocdt` / `rq` 支持 `yyyy-MM-dd` 或 `yyyy/MM/dd`。
 
-### 5.7 `apmt100` 供应商导入流程
+### 5.7 `axmt510` 订单变更申请流程
+
+调用 `POST /api/erp/yida/push` 时传入 `type: "axmt510"`。表头使用 ERP 原始字段;明细分别放入 `detailData1`(订单变更单身)、`detailData2`(交期明细)、`detailData3`(配件明细)。
+
+当前仅发起宜搭流程,不做 ERP 回写。表头「变更事件」、附件与已到物料相关字段无对应 ERP 字段,推送时不填。子表1 `xmegkk` 映射到「变更事件」选项:`数量变更` / `交期变更` / `其他变更`。
+
+```json
+{
+  "type": "axmt510",
+  "bm": "123456789",
+  "gs": "87",
+  "tjr": "EMP0001",
+  "mainData": {
+    "rq": "2026-09-23",
+    "bm": "123456789",
+    "gs": "87",
+    "tjr": "EMP0001",
+    "xmeeent": "87",
+    "xmeedocno": "SO-20260923-001",
+    "xmee900": "1",
+    "xmeedocdt": "2026/09/20",
+    "xmee001": 2,
+    "xmee002": "dingtalk-sales-user",
+    "xmee003": "dingtalk-dept-id",
+    "xmee004": "C001",
+    "pmaal004": "客户简称",
+    "xmee013": "Y",
+    "xmee042": 12800.5,
+    "xmee901": "2",
+    "xmee902": "2026/09/23",
+    "xmee903": "R01",
+    "xmee904": "交期调整"
+  },
+  "detailData": {
+    "detailData1": [
+      {
+        "xmegent": "87",
+        "xmegdocno": "SO-20260923-001",
+        "xmegseq": "10",
+        "xmeg900": "1",
+        "xmeg001": "ITEM-001",
+        "xmeg002": "feature-a",
+        "imaal003": "品名",
+        "imaal004": "规格",
+        "xmdc007": 100,
+        "xmdc012": "2026/09/30",
+        "xmdc015": 10.5,
+        "xmeg006": "PCS",
+        "xmeg007": 120,
+        "xmeg012": "2026/10/15",
+        "xmeg015": 11,
+        "xmeg046": 1100,
+        "xmeg047": 1243,
+        "xmeg048": 143,
+        "xmeg049": "RC01",
+        "xmeg053": "HC01",
+        "xmeg901": "2",
+        "xmegud010": "MO-001",
+        "xmegkk": "交期变更"
+      }
+    ],
+    "detailData2": [
+      {
+        "xmehent": "87",
+        "xmehdocno": "SO-20260923-001",
+        "xmehseq": "10",
+        "xmehseq1": "1",
+        "xmehseq2": "1",
+        "xmeh900": "1",
+        "xmeh001": "ITEM-001",
+        "xmeh004": "PCS",
+        "xmeh005": 120,
+        "xmeh009": "1",
+        "xmeh011": "2026/10/15"
+      }
+    ],
+    "detailData3": [
+      {
+        "xmfwucent": "87",
+        "xmfwucdocno": "SO-20260923-001",
+        "xmfwucseq": "10",
+        "xmfwucseq1": "1",
+        "xmfwuc001": "PART-001",
+        "imaal003": "配件品名",
+        "imaal004": "配件规格",
+        "xmfwucud001": "feat-p",
+        "xmfwuc006": 2,
+        "xmfwuc007": 1,
+        "xmfwuc008": "PCS",
+        "xmfwuc009": 240,
+        "xmfwuc900": "1",
+        "xmfwuc901": "2"
+      }
+    ]
+  }
+}
+```
+
+`tjr` 为员工工号;`xmee002` / `xmee003` 需传钉钉用户 ID / 部门 ID。日期字段支持 `yyyy-MM-dd` 或 `yyyy/MM/dd`。流程标题为 `订单变更申请流程-{订单单号}-{变更序}`。
+
+### 5.8 `apmt100` 供应商导入流程
 
 调用 `POST /api/erp/yida/push` 时传入 `type: "apmt100"`。该流程无明细,`detailData` 必须为 `{}`。
 

+ 501 - 0
src/main/java/com/malk/minglei/service/impl/yida/Axmt510FormPushHandler.java

@@ -0,0 +1,501 @@
+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;
+
+/**
+ * 订单变更申请流程(axmt510)ERP 推送到宜搭的处理器。
+ * <p>
+ * 仅负责发起流程;不做 ERP 回写。宜搭表头「变更事件」、附件与已到物料区无对应 ERP 字段,推送时不填。
+ */
+@Service
+public class Axmt510FormPushHandler implements YidaFormPushHandler {
+
+    public static final String TYPE = "axmt510";
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(Axmt510FormPushHandler.class);
+    private static final String START_PROCESS_ENDPOINT = "/v1.0/yida/processes/instances/start";
+    private static final String FORM_NAME = "\u8ba2\u5355\u53d8\u66f4\u7533\u8bf7\u6d41\u7a0b";
+    private static final String FORM_UUID = "FORM-8F318292B70D4ED39080EE0C631DC74BG3MD";
+    private static final String PROCESS_CODE = "TPROC--BHC66W71Q1N87Q0YFD7FVBNYXRJ62OIA378TM4";
+
+    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_mso1em48";
+    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 ORDER_NO_FIELD_ID = "textField_mt7zi4fy";
+    private static final String CHANGE_SEQ_FIELD_ID = "textField_mt82p09d";
+    private static final String ORDER_DATE_FIELD_ID = "dateField_mt82p09f";
+    private static final String VERSION_FIELD_ID = "numberField_mt82p090";
+    private static final String SALES_PERSON_FIELD_ID = "employeeField_mt82p08t";
+    private static final String SALES_DEPARTMENT_FIELD_ID = "departmentSelectField_mt82p08u";
+    private static final String PRICE_TAX_INCLUDED_FIELD_ID = "textField_mt82p092";
+    private static final String CUSTOMER_SHORT_NAME_FIELD_ID = "textField_mt82p08x";
+    private static final String CUSTOMER_CODE_FIELD_ID = "textField_mt80nq6v";
+    private static final String ORDER_AMOUNT_FIELD_ID = "numberField_mt8747pu";
+    private static final String CHANGE_TYPE_FIELD_ID = "textField_mt82p091";
+    private static final String CHANGE_DATE_FIELD_ID = "dateField_mt8747pm";
+    private static final String CHANGE_REASON_FIELD_ID = "textField_mt8747ps";
+    private static final String REMARK_FIELD_ID = "textField_mt82p097";
+
+    private static final String TABLE1_FIELD_ID = "tableField_szfz1tq1x";
+    private static final String T1_CHANGE_TYPE_FIELD_ID = "textField_szfz22fyf";
+    private static final String T1_PRODUCTION_ORDER_FIELD_ID = "textField_szfz3cca2";
+    private static final String T1_ENT_FIELD_ID = "textField_szfz5ljgx";
+    private static final String T1_CHANGE_EVENT_FIELD_ID = "selectField_mt8704bs";
+    private static final String T1_ORDER_NO_FIELD_ID = "textField_szfz6aq91";
+    private static final String T1_SEQ_FIELD_ID = "textField_szg07bfcn";
+    private static final String T1_CHANGE_SEQ_FIELD_ID = "textField_szg08jck1";
+    private static final String T1_ITEM_CODE_FIELD_ID = "textField_szg09edmz";
+    private static final String T1_FEATURE_FIELD_ID = "textField_szg0a66fa";
+    private static final String T1_ITEM_NAME_FIELD_ID = "textField_szg0bss4a";
+    private static final String T1_SPEC_FIELD_ID = "textField_szg0ceqy1";
+    private static final String T1_OLD_QTY_FIELD_ID = "numberField_szg0dtccz";
+    private static final String T1_OLD_DATE_FIELD_ID = "dateField_szg0e4ole";
+    private static final String T1_OLD_PRICE_FIELD_ID = "numberField_szg0fi31q";
+    private static final String T1_UNIT_FIELD_ID = "textField_szg0gvyi9";
+    private static final String T1_QTY_FIELD_ID = "numberField_szg0hxy1p";
+    private static final String T1_NEW_DATE_FIELD_ID = "dateField_szg0i15qu";
+    private static final String T1_PRICE_FIELD_ID = "numberField_szg0jbz6d";
+    private static final String T1_PRE_TAX_FIELD_ID = "numberField_szg0kq9ry";
+    private static final String T1_TAX_INCLUDED_FIELD_ID = "numberField_szg0l0qa8";
+    private static final String T1_TAX_FIELD_ID = "numberField_szg0mi5ng";
+    private static final String T1_REASON_CODE_FIELD_ID = "textField_szg0nvyt1";
+    private static final String T1_HOLD_REASON_FIELD_ID = "textField_szg0oijvw";
+
+    private static final String TABLE2_FIELD_ID = "tableField_szg0ppgwk";
+    private static final String T2_ENT_FIELD_ID = "textField_szg0qj321";
+    private static final String T2_ORDER_NO_FIELD_ID = "textField_szg0rxeaq";
+    private static final String T2_ORDER_ITEM_FIELD_ID = "textField_szg0sweog";
+    private static final String T2_ITEM_SEQ_FIELD_ID = "textField_szg0tr9pg";
+    private static final String T2_BATCH_SEQ_FIELD_ID = "textField_szg0utkgt";
+    private static final String T2_CHANGE_SEQ_FIELD_ID = "textField_szg0vnkgp";
+    private static final String T2_ITEM_CODE_FIELD_ID = "textField_szg0wmfd6";
+    private static final String T2_UNIT_FIELD_ID = "textField_szg0xdr6b";
+    private static final String T2_TOTAL_QTY_FIELD_ID = "numberField_szg0ycu5y";
+    private static final String T2_DELIVERY_TYPE_FIELD_ID = "textField_szg0zywse";
+    private static final String T2_DELIVERY_DATE_FIELD_ID = "dateField_szg010bkcz";
+
+    private static final String TABLE3_FIELD_ID = "tableField_szg011or3m";
+    private static final String T3_ENT_FIELD_ID = "textField_szg012wixs";
+    private static final String T3_DOC_NO_FIELD_ID = "textField_szg113yzwu";
+    private static final String T3_SEQ_FIELD_ID = "textField_szg114dozi";
+    private static final String T3_ITEM_SEQ_FIELD_ID = "textField_szg1150r3z";
+    private static final String T3_ITEM_CODE_FIELD_ID = "textField_szg116f94y";
+    private static final String T3_ITEM_NAME_FIELD_ID = "textField_szg117vhha";
+    private static final String T3_SPEC_FIELD_ID = "textField_szg118b8ug";
+    private static final String T3_FEATURE_FIELD_ID = "textField_szg119ljxs";
+    private static final String T3_COMPOSE_QTY_FIELD_ID = "numberField_szg11agf56";
+    private static final String T3_BASE_QTY_FIELD_ID = "numberField_szg11be5cl";
+    private static final String T3_UNIT_FIELD_ID = "textField_szg11cnr1x";
+    private static final String T3_DEMAND_QTY_FIELD_ID = "numberField_szg11dw9lj";
+    private static final String T3_CHANGE_SEQ_FIELD_ID = "textField_szg11evyco";
+    private static final String T3_CHANGE_TYPE_FIELD_ID = "textField_szg11f15ze";
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties applications;
+
+    /**
+     * 创建订单变更申请推送处理器。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     */
+    public Axmt510FormPushHandler(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), "axmt510 requires mainData.tjr");
+        Map<String, Object> formData = toYidaFormData(request, mainData, operatorUserId);
+        String title = requireText(toText(formData.get(TITLE_FIELD_ID)), "axmt510 requires mainData.xmeedocno");
+
+        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("xmeedocno"));
+        String changeSeq = toText(mainData.get("xmee900"));
+        String titleSuffix = docNo == null ? null
+                : (changeSeq == null ? docNo : docNo + "-" + changeSeq);
+        String title = titleSuffix == null ? null : FORM_NAME + "-" + titleSuffix;
+        Object company = toCompanyName(firstNonBlank(mainData.get("gs"), mainData.get("xmeeent"), 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("xmee902"), mainData.get("xmeedocdt"))));
+        putIfNotBlank(formData, FORM_TITLE_FIELD_ID, title);
+
+        putIfNotBlank(formData, ENTERPRISE_FIELD_ID, mainData.get("xmeeent"));
+        putIfNotBlank(formData, ORDER_NO_FIELD_ID, docNo);
+        putIfNotBlank(formData, CHANGE_SEQ_FIELD_ID, mainData.get("xmee900"));
+        putIfNotBlank(formData, ORDER_DATE_FIELD_ID, toDateMillis(mainData.get("xmeedocdt")));
+        putIfNotBlank(formData, VERSION_FIELD_ID, toDecimal(mainData.get("xmee001")));
+        putIfNotBlank(formData, SALES_PERSON_FIELD_ID, toStringList(mainData.get("xmee002")));
+        putIfNotBlank(formData, SALES_DEPARTMENT_FIELD_ID, toStringList(mainData.get("xmee003")));
+        putIfNotBlank(formData, CUSTOMER_CODE_FIELD_ID, mainData.get("xmee004"));
+        putIfNotBlank(formData, CUSTOMER_SHORT_NAME_FIELD_ID, mainData.get("pmaal004"));
+        putIfNotBlank(formData, PRICE_TAX_INCLUDED_FIELD_ID, mainData.get("xmee013"));
+        putIfNotBlank(formData, ORDER_AMOUNT_FIELD_ID, toDecimal(mainData.get("xmee042")));
+        putIfNotBlank(formData, CHANGE_TYPE_FIELD_ID, mainData.get("xmee901"));
+        putIfNotBlank(formData, CHANGE_DATE_FIELD_ID, toDateMillis(mainData.get("xmee902")));
+        putIfNotBlank(formData, CHANGE_REASON_FIELD_ID, mainData.get("xmee903"));
+        putIfNotBlank(formData, REMARK_FIELD_ID, mainData.get("xmee904"));
+
+        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);
+        }
+        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_CHANGE_TYPE_FIELD_ID, source.get("xmeg901"));
+            putIfNotBlank(row, T1_PRODUCTION_ORDER_FIELD_ID, source.get("xmegud010"));
+            putIfNotBlank(row, T1_ENT_FIELD_ID, source.get("xmegent"));
+            putIfNotBlank(row, T1_CHANGE_EVENT_FIELD_ID, source.get("xmegkk"));
+            putIfNotBlank(row, T1_ORDER_NO_FIELD_ID, source.get("xmegdocno"));
+            putIfNotBlank(row, T1_SEQ_FIELD_ID, source.get("xmegseq"));
+            putIfNotBlank(row, T1_CHANGE_SEQ_FIELD_ID, source.get("xmeg900"));
+            putIfNotBlank(row, T1_ITEM_CODE_FIELD_ID, source.get("xmeg001"));
+            putIfNotBlank(row, T1_FEATURE_FIELD_ID, source.get("xmeg002"));
+            putIfNotBlank(row, T1_ITEM_NAME_FIELD_ID, source.get("imaal003"));
+            putIfNotBlank(row, T1_SPEC_FIELD_ID, source.get("imaal004"));
+            putIfNotBlank(row, T1_OLD_QTY_FIELD_ID, toDecimal(source.get("xmdc007")));
+            putIfNotBlank(row, T1_OLD_DATE_FIELD_ID, toDateMillis(source.get("xmdc012")));
+            putIfNotBlank(row, T1_OLD_PRICE_FIELD_ID, toDecimal(source.get("xmdc015")));
+            putIfNotBlank(row, T1_UNIT_FIELD_ID, source.get("xmeg006"));
+            putIfNotBlank(row, T1_QTY_FIELD_ID, toDecimal(source.get("xmeg007")));
+            putIfNotBlank(row, T1_NEW_DATE_FIELD_ID, toDateMillis(source.get("xmeg012")));
+            putIfNotBlank(row, T1_PRICE_FIELD_ID, toDecimal(source.get("xmeg015")));
+            putIfNotBlank(row, T1_PRE_TAX_FIELD_ID, toDecimal(source.get("xmeg046")));
+            putIfNotBlank(row, T1_TAX_INCLUDED_FIELD_ID, toDecimal(source.get("xmeg047")));
+            putIfNotBlank(row, T1_TAX_FIELD_ID, toDecimal(source.get("xmeg048")));
+            putIfNotBlank(row, T1_REASON_CODE_FIELD_ID, source.get("xmeg049"));
+            putIfNotBlank(row, T1_HOLD_REASON_FIELD_ID, source.get("xmeg053"));
+            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("xmehent"));
+            putIfNotBlank(row, T2_ORDER_NO_FIELD_ID, source.get("xmehdocno"));
+            putIfNotBlank(row, T2_ORDER_ITEM_FIELD_ID, source.get("xmehseq"));
+            putIfNotBlank(row, T2_ITEM_SEQ_FIELD_ID, source.get("xmehseq1"));
+            putIfNotBlank(row, T2_BATCH_SEQ_FIELD_ID, source.get("xmehseq2"));
+            putIfNotBlank(row, T2_CHANGE_SEQ_FIELD_ID, source.get("xmeh900"));
+            putIfNotBlank(row, T2_ITEM_CODE_FIELD_ID, source.get("xmeh001"));
+            putIfNotBlank(row, T2_UNIT_FIELD_ID, source.get("xmeh004"));
+            putIfNotBlank(row, T2_TOTAL_QTY_FIELD_ID, toDecimal(source.get("xmeh005")));
+            putIfNotBlank(row, T2_DELIVERY_TYPE_FIELD_ID, source.get("xmeh009"));
+            putIfNotBlank(row, T2_DELIVERY_DATE_FIELD_ID, toDateMillis(source.get("xmeh011")));
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    /**
+     * 映射子表3(订单变更配件明细)。
+     */
+    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("xmfwucent"));
+            putIfNotBlank(row, T3_DOC_NO_FIELD_ID, source.get("xmfwucdocno"));
+            putIfNotBlank(row, T3_SEQ_FIELD_ID, source.get("xmfwucseq"));
+            putIfNotBlank(row, T3_ITEM_SEQ_FIELD_ID, source.get("xmfwucseq1"));
+            putIfNotBlank(row, T3_ITEM_CODE_FIELD_ID, source.get("xmfwuc001"));
+            putIfNotBlank(row, T3_ITEM_NAME_FIELD_ID, source.get("imaal003"));
+            putIfNotBlank(row, T3_SPEC_FIELD_ID, source.get("imaal004"));
+            putIfNotBlank(row, T3_FEATURE_FIELD_ID, source.get("xmfwucud001"));
+            putIfNotBlank(row, T3_COMPOSE_QTY_FIELD_ID, toDecimal(source.get("xmfwuc006")));
+            putIfNotBlank(row, T3_BASE_QTY_FIELD_ID, toDecimal(source.get("xmfwuc007")));
+            putIfNotBlank(row, T3_UNIT_FIELD_ID, source.get("xmfwuc008"));
+            putIfNotBlank(row, T3_DEMAND_QTY_FIELD_ID, toDecimal(source.get("xmfwuc009")));
+            putIfNotBlank(row, T3_CHANGE_SEQ_FIELD_ID, source.get("xmfwuc900"));
+            putIfNotBlank(row, T3_CHANGE_TYPE_FIELD_ID, source.get("xmfwuc901"));
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    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());
+    }
+
+    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");
+    }
+}

+ 206 - 0
src/test/java/com/malk/minglei/service/impl/yida/Axmt510FormPushHandlerTest.java

@@ -0,0 +1,206 @@
+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;
+
+/**
+ * 订单变更申请(axmt510)字段映射与发起参数校验。
+ */
+class Axmt510FormPushHandlerTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushMapsAxmt510PayloadToOrderChangeProcess() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-axmt510");
+        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.setRq("2026-09-23");
+        mainData.setExtraField("xmeeent", "87");
+        mainData.setExtraField("xmeedocno", "SO-20260923-001");
+        mainData.setExtraField("xmee900", "1");
+        mainData.setExtraField("xmeedocdt", "2026/09/20");
+        mainData.setExtraField("xmee001", "2");
+        mainData.setExtraField("xmee002", "sales-user");
+        mainData.setExtraField("xmee003", "sales-dept");
+        mainData.setExtraField("xmee004", "C001");
+        mainData.setExtraField("pmaal004", "客户简称A");
+        mainData.setExtraField("xmee013", "Y");
+        mainData.setExtraField("xmee042", "12800.50");
+        mainData.setExtraField("xmee901", "2");
+        mainData.setExtraField("xmee902", "2026/09/23");
+        mainData.setExtraField("xmee903", "R01");
+        mainData.setExtraField("xmee904", "交期调整");
+
+        Map<String, Object> detail1 = new LinkedHashMap<String, Object>();
+        detail1.put("xmegent", "87");
+        detail1.put("xmegdocno", "SO-20260923-001");
+        detail1.put("xmegseq", "10");
+        detail1.put("xmeg900", "1");
+        detail1.put("xmeg001", "ITEM-001");
+        detail1.put("xmeg002", "feature-a");
+        detail1.put("imaal003", "品名A");
+        detail1.put("imaal004", "规格A");
+        detail1.put("xmdc007", "100");
+        detail1.put("xmdc012", "2026/09/30");
+        detail1.put("xmdc015", "10.5");
+        detail1.put("xmeg006", "PCS");
+        detail1.put("xmeg007", "120");
+        detail1.put("xmeg012", "2026/10/15");
+        detail1.put("xmeg015", "11");
+        detail1.put("xmeg046", "1100");
+        detail1.put("xmeg047", "1243");
+        detail1.put("xmeg048", "143");
+        detail1.put("xmeg049", "RC01");
+        detail1.put("xmeg053", "HC01");
+        detail1.put("xmeg901", "2");
+        detail1.put("xmegud010", "MO-001");
+        detail1.put("xmegkk", "交期变更");
+
+        Map<String, Object> detail2 = new LinkedHashMap<String, Object>();
+        detail2.put("xmehent", "87");
+        detail2.put("xmehdocno", "SO-20260923-001");
+        detail2.put("xmehseq", "10");
+        detail2.put("xmehseq1", "1");
+        detail2.put("xmehseq2", "1");
+        detail2.put("xmeh900", "1");
+        detail2.put("xmeh001", "ITEM-001");
+        detail2.put("xmeh004", "PCS");
+        detail2.put("xmeh005", "120");
+        detail2.put("xmeh009", "1");
+        detail2.put("xmeh011", "2026/10/15");
+
+        Map<String, Object> detail3 = new LinkedHashMap<String, Object>();
+        detail3.put("xmfwucent", "87");
+        detail3.put("xmfwucdocno", "SO-20260923-001");
+        detail3.put("xmfwucseq", "10");
+        detail3.put("xmfwucseq1", "1");
+        detail3.put("xmfwuc001", "PART-001");
+        detail3.put("imaal003", "配件品名");
+        detail3.put("imaal004", "配件规格");
+        detail3.put("xmfwucud001", "feat-p");
+        detail3.put("xmfwuc006", "2");
+        detail3.put("xmfwuc007", "1");
+        detail3.put("xmfwuc008", "PCS");
+        detail3.put("xmfwuc009", "240");
+        detail3.put("xmfwuc900", "1");
+        detail3.put("xmfwuc901", "2");
+
+        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));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(Axmt510FormPushHandler.TYPE);
+        request.setOriginatorUserId("dingtalk-user-001");
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+
+        Axmt510FormPushHandler handler = new Axmt510FormPushHandler(yidaClient, ddConf, applications);
+
+        assertEquals(Collections.singletonList("axmt510"), handler.getTypes());
+        assertEquals("process-instance-axmt510", 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-8F318292B70D4ED39080EE0C631DC74BG3MD", body.get("formUuid"));
+        assertEquals("TPROC--BHC66W71Q1N87Q0YFD7FVBNYXRJ62OIA378TM4", body.get("processCode"));
+        assertEquals("\u8ba2\u5355\u53d8\u66f4\u7533\u8bf7\u6d41\u7a0b-SO-20260923-001-1", body.get("title"));
+
+        JSONObject formData = JSONObject.parseObject(String.valueOf(body.get("formDataJson")));
+        assertEquals("87", formData.getString("textField_mt82p09i"));
+        assertEquals("SO-20260923-001", formData.getString("textField_mt7zi4fy"));
+        assertEquals("1", formData.getString("textField_mt82p09d"));
+        assertEquals("2", formData.getString("numberField_mt82p090"));
+        assertEquals("sales-user", formData.getJSONArray("employeeField_mt82p08t").getString(0));
+        assertEquals("sales-dept", formData.getJSONArray("departmentSelectField_mt82p08u").getString(0));
+        assertEquals("C001", formData.getString("textField_mt80nq6v"));
+        assertEquals("\u5ba2\u6237\u7b80\u79f0A", formData.getString("textField_mt82p08x"));
+        assertEquals("Y", formData.getString("textField_mt82p092"));
+        assertEquals("12800.50", formData.getString("numberField_mt8747pu"));
+        assertEquals("2", formData.getString("textField_mt82p091"));
+        assertEquals("R01", formData.getString("textField_mt8747ps"));
+        assertEquals("\u4ea4\u671f\u8c03\u6574", formData.getString("textField_mt82p097"));
+        assertEquals("\u660e\u78ca", formData.getString("selectField_mso1em48"));
+        assertEquals("dingtalk-user-001", formData.getJSONArray("employeeField_msh186rr").getString(0));
+        // 表头变更事件、附件、已到物料区无 ERP 字段,推送时不填
+        assertNull(formData.get("selectField_mt8747pv"));
+        assertNull(formData.get("attachmentField_mt8747pt"));
+        assertNull(formData.get("textField_mt8747q1"));
+
+        JSONArray table1 = formData.getJSONArray("tableField_szfz1tq1x");
+        assertEquals(1, table1.size());
+        assertEquals("2", table1.getJSONObject(0).getString("textField_szfz22fyf"));
+        assertEquals("MO-001", table1.getJSONObject(0).getString("textField_szfz3cca2"));
+        assertEquals("\u4ea4\u671f\u53d8\u66f4", table1.getJSONObject(0).getString("selectField_mt8704bs"));
+        assertEquals("ITEM-001", table1.getJSONObject(0).getString("textField_szg09edmz"));
+        assertEquals("100", table1.getJSONObject(0).getString("numberField_szg0dtccz"));
+        assertEquals("120", table1.getJSONObject(0).getString("numberField_szg0hxy1p"));
+        assertEquals("11", table1.getJSONObject(0).getString("numberField_szg0jbz6d"));
+
+        JSONArray table2 = formData.getJSONArray("tableField_szg0ppgwk");
+        assertEquals("ITEM-001", table2.getJSONObject(0).getString("textField_szg0wmfd6"));
+        assertEquals("120", table2.getJSONObject(0).getString("numberField_szg0ycu5y"));
+
+        JSONArray table3 = formData.getJSONArray("tableField_szg011or3m");
+        assertEquals("PART-001", table3.getJSONObject(0).getString("textField_szg116f94y"));
+        assertEquals("240", table3.getJSONObject(0).getString("numberField_szg11dw9lj"));
+        assertEquals("2", table3.getJSONObject(0).getString("textField_szg11f15ze"));
+    }
+
+    @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("xmeedocno", "SO-001");
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setMainData(mainData);
+        request.setDetailData(Collections.emptyMap());
+
+        Axmt510FormPushHandler handler = new Axmt510FormPushHandler(yidaClient, ddConf, applications);
+        assertThrows(IllegalArgumentException.class, () -> handler.push(request));
+    }
+}