Sfoglia il codice sorgente

chore: initialize minglei project

EDY 5 giorni fa
commit
d613c88664
79 ha cambiato i file con 35705 aggiunte e 0 eliminazioni
  1. 7 0
      .gitignore
  2. 1 0
      .mvn/maven.config
  3. 366 0
      ERP-YIDA-PUSH-API.md
  4. 463 0
      README.md
  5. 85 0
      pom.xml
  6. 14 0
      src/main/java/com/malk/minglei/MjavaMingleiApplication.java
  7. 18 0
      src/main/java/com/malk/minglei/config/DingTalkRosterConfiguration.java
  8. 45 0
      src/main/java/com/malk/minglei/config/EmployeeRosterConfiguration.java
  9. 106 0
      src/main/java/com/malk/minglei/config/EmployeeRosterProperties.java
  10. 99 0
      src/main/java/com/malk/minglei/config/HttpRequestResponseLoggingFilter.java
  11. 28 0
      src/main/java/com/malk/minglei/config/MingleiPortConfiguration.java
  12. 29 0
      src/main/java/com/malk/minglei/config/MingleiPortProperties.java
  13. 75 0
      src/main/java/com/malk/minglei/config/MjavaYidaConfiguration.java
  14. 48 0
      src/main/java/com/malk/minglei/config/YidaApplicationsProperties.java
  15. 46 0
      src/main/java/com/malk/minglei/controller/EmployeeRosterController.java
  16. 43 0
      src/main/java/com/malk/minglei/controller/ErpYidaPushController.java
  17. 62 0
      src/main/java/com/malk/minglei/controller/GlobalExceptionHandler.java
  18. 75 0
      src/main/java/com/malk/minglei/controller/MingleiPortController.java
  19. 103 0
      src/main/java/com/malk/minglei/controller/YidaProcessController.java
  20. 43 0
      src/main/java/com/malk/minglei/dto/ApiResponse.java
  21. 42 0
      src/main/java/com/malk/minglei/dto/DepartmentUserIdsResponse.java
  22. 51 0
      src/main/java/com/malk/minglei/dto/DepartmentUserQueryRequest.java
  23. 95 0
      src/main/java/com/malk/minglei/dto/EmployeeRosterInfo.java
  24. 20 0
      src/main/java/com/malk/minglei/dto/EmployeeRosterQueryRequest.java
  25. 38 0
      src/main/java/com/malk/minglei/dto/EmployeeRosterSyncResult.java
  26. 116 0
      src/main/java/com/malk/minglei/dto/ErpYidaPushMainData.java
  27. 90 0
      src/main/java/com/malk/minglei/dto/ErpYidaPushRequest.java
  28. 42 0
      src/main/java/com/malk/minglei/dto/MingleiDocumentQueryRequest.java
  29. 86 0
      src/main/java/com/malk/minglei/dto/MingleiPortRequest.java
  30. 45 0
      src/main/java/com/malk/minglei/dto/StartProcessRequest.java
  31. 25 0
      src/main/java/com/malk/minglei/dto/YidaOperationParam.java
  32. 59 0
      src/main/java/com/malk/minglei/dto/YidaProcessStartRequest.java
  33. 11 0
      src/main/java/com/malk/minglei/service/employee/DingTalkEmployeeRosterClient.java
  34. 15 0
      src/main/java/com/malk/minglei/service/employee/EmployeeRosterService.java
  35. 123 0
      src/main/java/com/malk/minglei/service/impl/employee/DingTalkEmployeeRosterClientImpl.java
  36. 86 0
      src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRecord.java
  37. 116 0
      src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepository.java
  38. 31 0
      src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterScheduler.java
  39. 532 0
      src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImpl.java
  40. 132 0
      src/main/java/com/malk/minglei/service/impl/port/MingleiPortServiceImpl.java
  41. 469 0
      src/main/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandler.java
  42. 89 0
      src/main/java/com/malk/minglei/service/impl/yida/BomYidaProcessHandler.java
  43. 253 0
      src/main/java/com/malk/minglei/service/impl/yida/Cpmp402YidaProcessHandler.java
  44. 392 0
      src/main/java/com/malk/minglei/service/impl/yida/CustomerReleaseFormPushHandler.java
  45. 29 0
      src/main/java/com/malk/minglei/service/impl/yida/DingTalkYidaProcessOpenApiClient.java
  46. 296 0
      src/main/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImpl.java
  47. 357 0
      src/main/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImpl.java
  48. 416 0
      src/main/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandler.java
  49. 471 0
      src/main/java/com/malk/minglei/service/impl/yida/MlWorkOrderCraftPriceFormPushHandler.java
  50. 54 0
      src/main/java/com/malk/minglei/service/impl/yida/YidaProcessServiceImpl.java
  51. 19 0
      src/main/java/com/malk/minglei/service/port/MingleiPortService.java
  52. 18 0
      src/main/java/com/malk/minglei/service/yida/ErpYidaPushService.java
  53. 35 0
      src/main/java/com/malk/minglei/service/yida/MingleiDocumentService.java
  54. 15 0
      src/main/java/com/malk/minglei/service/yida/YidaFormPushHandler.java
  55. 25 0
      src/main/java/com/malk/minglei/service/yida/YidaProcessHandler.java
  56. 13 0
      src/main/java/com/malk/minglei/service/yida/YidaProcessOpenApiClient.java
  57. 14 0
      src/main/java/com/malk/minglei/service/yida/YidaProcessService.java
  58. 47 0
      src/main/resources/application.yml
  59. 17 0
      src/main/resources/db/employee_roster.sql
  60. 50 0
      src/main/resources/logback-spring.xml
  61. 26972 0
      src/main/resources/static/mjs/mjs.min.js
  62. 22 0
      src/test/java/com/malk/minglei/MjavaMingleiApplicationTest.java
  63. 58 0
      src/test/java/com/malk/minglei/controller/EmployeeRosterControllerTest.java
  64. 103 0
      src/test/java/com/malk/minglei/controller/ErpYidaPushControllerTest.java
  65. 46 0
      src/test/java/com/malk/minglei/controller/YidaProcessControllerTest.java
  66. 28 0
      src/test/java/com/malk/minglei/dto/ApiResponseTest.java
  67. 28 0
      src/test/java/com/malk/minglei/dto/DepartmentUserIdsResponseTest.java
  68. 480 0
      src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterLiveTest.java
  69. 208 0
      src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java
  70. 74 0
      src/test/java/com/malk/minglei/service/impl/port/MingleiPortServiceImplTest.java
  71. 165 0
      src/test/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandlerTest.java
  72. 106 0
      src/test/java/com/malk/minglei/service/impl/yida/Cpmp402YidaProcessHandlerTest.java
  73. 115 0
      src/test/java/com/malk/minglei/service/impl/yida/CustomerReleaseFormPushHandlerTest.java
  74. 245 0
      src/test/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImplTest.java
  75. 72 0
      src/test/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImplTest.java
  76. 136 0
      src/test/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandlerTest.java
  77. 184 0
      src/test/java/com/malk/minglei/service/impl/yida/MlWorkOrderCraftPriceFormPushHandlerTest.java
  78. 46 0
      src/test/java/com/malk/minglei/service/impl/yida/YidaProcessServiceImplTest.java
  79. 27 0
      src/test/js/mjs-sign.test.js

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+target/
+.idea/
+.m2/
+.cache/
+log.path_IS_UNDEFINED/
+logs/
+*.log

+ 1 - 0
.mvn/maven.config

@@ -0,0 +1 @@
+-Dmaven.repo.local=C:/app/maven/maven_repository

+ 366 - 0
ERP-YIDA-PUSH-API.md

@@ -0,0 +1,366 @@
+# ERP 推送宜搭数据接口文档
+
+## 1. 接口说明
+
+ERP(T100)向明磊业务集成服务推送单据数据。当前接口按 `type` 分发到对应的宜搭处理器,支持发起宜搭流程。
+
+## 2. 基本信息
+
+| 项目 | 内容 |
+| --- | --- |
+| 接口名称 | ERP 推送宜搭数据 |
+| 请求方式 | `POST` |
+| 请求地址 | `http://10.0.0.250/api/erp/yida/push` |
+| 请求格式 | `application/json` |
+| 返回格式 | `application/json` |
+| 当前处理方式 | 接收入参、打印日志、按 `type` 路由到宜搭流程处理器 |
+| 当前支持类型 | `cpmp402`、`ml_work_order_craft_price` |
+| `cpmp402` 对应单据 | 包材核价单通知 |
+| `ml_work_order_craft_price` 对应单据 | ML-工单工艺工价审定 |
+| 目标宜搭应用 | `APP_RJJL69QUIZVQSV7YC8TL` |
+
+## 3. 请求头
+
+```http
+Content-Type: application/json
+```
+
+## 4. 请求参数
+
+### 4.1 请求体
+
+```json
+{
+  "type": "cpmp402",
+  "mainData": {
+    "rq": "2026-09-08",
+    "bm": "ML07000000",
+    "tjr": "3075",
+    "gs": "ML",
+    "t100id": "ML",
+    "ent": 87,
+    "t100bm": "ML07000000"
+  },
+  "detailData": {
+    "detailData": [
+      {
+        "hjdh": "MLCH326090800001",
+        "gysbh": "113009",
+        "gysmc": "南京鼎泰五金工具有限公司",
+        "t100id": "ML",
+        "ent": "87",
+        "yy": "zh_CN"
+      }
+    ]
+  }
+}
+```
+
+### 4.2 顶层字段
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `type` | `String` | 是 | 单据类型路由键。`cpmp402` 表示包材核价单流程;`ml_work_order_craft_price` 表示 `ML-工单工艺工价审定` 流程。 |
+| `mainData` | `Object` | 是 | 固定主数据对象。 |
+| `detailData` | `Object` | 是 | 明细数据外壳。必须是对象,且内部必须包含 `detailData` 数组。 |
+
+### 4.3 `mainData` 字段
+
+| 字段 | 类型 | 必填 | 说明                      |
+| --- | --- | --- |-------------------------|
+| `rq` | `String` | 否 | 日期(yyyy-MM-dd)。 |
+| `bm` | `String` | 否 | 部门(钉钉部门编号)。             |
+| `tjr` | `String` | 是 | 提交人(工号),不能为空字符串或空白字符。   |
+| `gs` | `String` | 否 | 公司(代码)。                 |
+| `t100id` | `String` | 否 | T100 用户或业务对象标识。         |
+| `ent` | `String` | 否 | 地区(代码)。                 |
+| `t100bm` | `String` | 否 | T100 部门。                |
+
+`mainData` 中除 `tjr` 外的字段可以传空字符串、空值或不填写。服务端会统一处理 `mainData`:`tjr` 按员工花名册的工号查询钉钉 `userId`,并作为宜搭流程发起人;`rq`、`bm`、`gs`、`t100id`、`ent`、`t100bm` 会作为通用默认元数据补入内部明细对象,供后续所有走通用接口的 `type` 复用。若工号不存在或没有钉钉 ID,接口返回 HTTP 400。
+
+### 4.4 `detailData` 明细字段
+
+`detailData` 的外层结构固定为对象,且必须包含 `detailData` 数组。数组内对象字段根据 `type` 对应的单据类型定义;后续所有走通用接口的类型都必须遵守该结构。
+
+以 `cpmp402` 包材核价单通知为例,推荐字段如下:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `hjdh` | `String` | 否 | 核价单号。 |
+| `gysbh` | `String` | 否 | 供应商编号。 |
+| `gysmc` | `String` | 否 | 供应商名称。 |
+| `t100id` | `String` | 否 | T100 业务对象标识。 |
+| `ent` | `String` | 否 | 地区。 |
+| `yy` | `String` | 否 | 语言。 |
+
+接口不会校验明细对象的具体字段,后续新增单据类型或单据字段时,可直接扩展请求内容。
+
+## 5. 成功响应
+
+HTTP 状态码:`200 OK`
+
+响应示例:
+
+```json
+{
+  "success": true,
+  "code": "200",
+  "message": "SUCCESS"
+}
+```
+
+该响应由基座 `com.malk.server.common.McR` 的 `McR.success()` 生成;宜搭流程实例号会写入服务日志。
+
+## 6. 参数校验失败
+
+以下情况会被拒绝:
+
+- `type` 缺失或为空白字符串。
+- `mainData` 缺失或为 `null`。
+- `mainData.tjr` 缺失或为空白字符串。
+- `mainData.tjr` 对应工号在员工花名册中不存在,或该员工没有钉钉 `userId`。
+- `detailData` 缺失、为 `null`、不是对象,或不包含数组字段 `detailData.detailData`。
+- 请求 JSON 格式错误。
+
+HTTP 状态码:`400 Bad Request`
+
+示例:
+
+```json
+{
+  "timestamp": "2026-09-07T13:50:00.000+00:00",
+  "status": 400,
+  "error": "Bad Request",
+  "path": "/api/erp/yida/push"
+}
+```
+
+`detailData.detailData` 可以为空数组:
+
+```json
+{
+  "type": "cpmp402",
+  "mainData": {
+    "tjr": "3075"
+  },
+  "detailData": {
+    "detailData": []
+  }
+}
+```
+
+也可以使用对象格式:
+
+```json
+{
+  "type": "cpmp402",
+  "mainData": {
+    "rq": "2026-09-08",
+    "bm": "ML07000000",
+    "tjr": "3075",
+    "gs": "ML",
+    "t100id": "ML",
+    "ent": 87,
+    "t100bm": "ML07000000"
+  },
+  "detailData": {
+    "detailData": [
+      {
+        "hjdh": "MLCH326090800001",
+        "gysbh": "113009",
+        "gysmc": "南京鼎泰五金工具有限公司",
+        "t100id": "ML",
+        "ent": "87",
+        "yy": "zh_CN"
+      }
+    ]
+  }
+}
+```
+
+## 7. 宜搭调用失败
+
+当底层宜搭或钉钉接口返回错误时,HTTP 状态码为 `502 Bad Gateway`,响应会返回上游错误码和错误信息,便于定位字段格式、权限或配置问题:
+
+```json
+{
+  "code": "DINGTALK_YIDA_ERROR",
+  "message": "上游字段校验失败"
+}
+```
+
+如果响应仍为 `YIDA_CALL_FAILED`,说明不是宜搭业务错误码,而是网络、JSON 序列化或其他未分类异常;此时需要查看应用错误日志中的异常堆栈。
+
+`ml_work_order_craft_price` 会复用通用 `mainData` 处理结果:`mainData.tjr` 会按工号查询员工花名册,取 `dingtalk_id` 作为流程发起人并写入申请人字段 `employeeField_msh186rr`;`mainData.rq` 作为默认申请日期;`mainData.gs` 写入申请公司字段 `textField_mu1yk6l5`;`mainData.bm` 部门 ID 直接以数组写入申请部门字段 `departmentSelectField_msh186ry`。
+
+## 8. 日志说明
+
+请求成功进入 Service 后,会记录以下两类 `INFO` 日志:
+
+```text
+ERP Yida push request: {请求 JSON}
+ERP Yida push response: {McR 响应 JSON}
+```
+
+示例:
+
+```text
+ERP Yida push request: {"type":"cpmp402","mainData":{"tjr":"ERP_USER_001"},"detailData":[]}
+ERP Yida push response: {"success":true,"code":"200","message":"SUCCESS"}
+```
+
+当前日志记录的是完整业务请求内容,以及宜搭返回的流程实例号。生产环境如请求中包含密码、令牌或其他敏感信息,应在 ERP 端避免传输无关敏感字段;后续如接入敏感字段,需要增加日志脱敏策略。
+
+## 9. 调用示例
+
+### 9.1 PowerShell
+
+```powershell
+$body = @{
+    type = "cpmp402"
+    mainData = @{
+        rq = "2026-09-08"
+        bm = "ML07000000"
+        tjr = "3075"
+        gs = "ML"
+        t100id = "ML"
+        ent = 87
+        t100bm = "ML07000000"
+    }
+    detailData = @{
+        detailData = @(
+            @{
+                hjdh = "MLCH326090800001"
+                gysbh = "113009"
+                gysmc = "南京鼎泰五金工具有限公司"
+                t100id = "ML"
+                ent = "87"
+                yy = "zh_CN"
+            }
+        )
+    }
+} | ConvertTo-Json -Depth 10
+
+Invoke-RestMethod `
+    -Method Post `
+    -Uri "http://10.0.0.250/api/erp/yida/push" `
+    -ContentType "application/json" `
+    -Body $body
+```
+
+### 9.2 curl
+
+```bash
+curl -X POST "http://10.0.0.250/api/erp/yida/push" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "type": "cpmp402",
+    "mainData": {
+      "rq": "2026-09-08",
+      "bm": "ML07000000",
+      "tjr": "3075",
+      "gs": "ML",
+      "t100id": "ML",
+      "ent": 87,
+      "t100bm": "ML07000000"
+    },
+    "detailData": {
+      "detailData": [
+        {
+          "hjdh": "MLCH326090800001",
+          "gysbh": "113009",
+          "gysmc": "南京鼎泰五金工具有限公司",
+          "t100id": "ML",
+          "ent": "87",
+          "yy": "zh_CN"
+        }
+      ]
+    }
+  }'
+```
+
+### 9.3 `ml_work_order_craft_price` 流程发起示例
+
+```json
+{
+  "type": "ml_work_order_craft_price",
+  "mainData": {
+    "rq": "2026-09-08",
+    "tjr": "3075"
+  },
+  "detailData": {
+    "detailData": [
+      {
+        "标题": "工单工艺工价审定-GD-001",
+        "工单号": "GD-001",
+        "生产令": "MO-001",
+        "生产料号": "ITEM-001",
+        "品名": "电池包",
+        "规格": "24V",
+        "组装|包装": "组装",
+        "固定工价": "10.5",
+        "总单位工价": "17.5",
+        "明细表1": [
+          {
+            "工序编号": "GX-001",
+            "名称": "装配",
+            "数量": "2",
+            "工价": "3.5",
+            "合计": "7.0"
+          }
+        ],
+        "明细表2": [
+          {
+            "项次": "10",
+            "料号": "MAT-001",
+            "品名": "电池盒",
+            "规格": "A1",
+            "用量": "1.2",
+            "t100id": "ML",
+            "车间线长": "leader-001",
+            "企业编号": "87"
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+该类型发起宜搭流程,流程表单名 `ML-工单工艺工价审定`,表单 UUID `FORM-4403B45A7F184CB79088FCDAFD2FD925LZ3K`,流程编码 `TPROC--WG966BA1VWF8Q1K4OGF8H9KW47RR3ZREH0YSM2`。字段 ID 已通过 OpenYida schema 获取,服务端支持中文字段名、常见英文/拼音字段名,也支持调用方在 `detailData.detailData[]` 的明细对象中直接传入宜搭 `fieldId` 覆盖默认映射。
+
+申请人、申请公司和申请部门对应字段固定为:
+
+- 申请人:`employeeField_msh186rr`,固定由 `mainData.tjr` 工号查询员工花名册后取 `dingtalk_id` 写入。
+- 申请公司:`textField_mu1yk6l5`,优先取 `mainData.gs`;`mainData.gs` 为空时可用明细中的 `申请公司`、`applyCompany`、`company` 兜底。
+- 申请部门:`departmentSelectField_msh186ry`,优先取 `mainData.bm` 部门 ID 直接写入;`mainData.bm` 为空时可用明细中的 `申请部门`、`applyDepartment`、`department`、`deptId` 兜底。
+
+如果 `mainData.gs` 或 `mainData.bm` 为空,需要在明细中兜底申请公司和申请部门,请传申请公司文本值与钉钉部门 ID:
+
+```json
+{
+  "detailData": {
+    "detailData": [
+      {
+        "申请公司": "浙江明磊锂能源科技股份有限公司",
+        "申请部门": ["123456"]
+      }
+    ]
+  }
+}
+```
+
+## 10. 单据类型映射
+
+| `type` | 单据名称 | 目标宜搭应用 | 当前状态 |
+| --- | --- | --- | --- |
+| `cpmp402` | 包材核价单 | `APP_RJJL69QUIZVQSV7YC8TL` | 已按 `type` 路由到宜搭流程处理器 |
+| `ml_work_order_craft_price` | ML-工单工艺工价审定 | `APP_RJJL69QUIZVQSV7YC8TL` | 已按 `type` 路由到宜搭流程处理器 |
+
+## 11. 当前处理边界
+
+- 当前支持 `cpmp402` 和 `ml_work_order_craft_price`。
+- 当前 `type` 作为路由键,不作为表单 ID。
+- 当前发起人 `originatorUserId` 由 `mainData.tjr` 工号通过员工花名册解析。
+- 当前包材核价单表单 ID 和流程编码由代码中的 `Cpmp402YidaProcessHandler` 维护。
+- 当前 `ML-工单工艺工价审定` 是流程表单,必须走流程发起接口;表单 ID 和流程编码由代码中的 `MlWorkOrderCraftPriceFormPushHandler` 维护。

+ 463 - 0
README.md

@@ -0,0 +1,463 @@
+# mjava-minglei
+
+明磊业务集成服务。项目基于 Spring Boot 2.7 构建,用于对接钉钉、宜搭、员工花名册以及明磊外部 T100 接口。
+
+本文档说明当前工程的模块边界、配置项、REST 接口、ERP 推送规则和本地构建方式。文档中的凭证均使用占位符,真实密钥必须通过环境变量或部署配置注入。
+
+## 1. 技术栈
+
+| 技术 | 说明 |
+| --- | --- |
+| Java | JDK 8 |
+| Spring Boot | 2.7.18 |
+| Spring MVC | REST Controller、参数绑定、异常处理 |
+| Spring Validation | 请求参数校验 |
+| Maven | 构建和依赖管理 |
+| `com.malk:mjava` | 钉钉、宜搭客户端和基础模型,当前版本 `0.0.3` |
+| Jackson / Fastjson | JSON 绑定、序列化和表单字段组装 |
+| RestTemplate | 调用外部 T100 HTTP 接口 |
+| Logback + SLF4J | 应用日志、错误日志和请求响应日志 |
+| JUnit 5 + Mockito | 单元测试 |
+
+## 2. 工程结构
+
+```text
+src/main/java/com/malk/minglei
+|-- MjavaMingleiApplication.java
+|-- config
+|   |-- DingTalkRosterConfiguration.java
+|   |-- EmployeeRosterConfiguration.java
+|   |-- EmployeeRosterProperties.java
+|   |-- HttpRequestResponseLoggingFilter.java
+|   |-- MingleiPortConfiguration.java
+|   |-- MingleiPortProperties.java
+|   |-- MjavaYidaConfiguration.java
+|   `-- YidaApplicationsProperties.java
+|-- controller
+|   |-- EmployeeRosterController.java
+|   |-- ErpYidaPushController.java
+|   |-- GlobalExceptionHandler.java
+|   |-- MingleiPortController.java
+|   `-- YidaProcessController.java
+|-- dto
+`-- service
+    |-- employee
+    |-- port
+    |-- yida
+    `-- impl
+        |-- employee
+        |-- port
+        `-- yida
+```
+
+### 2.1 分层职责
+
+| 层级 | 职责 |
+| --- | --- |
+| `controller` | 暴露 HTTP 接口,处理请求校验和响应包装 |
+| `dto` | 请求体、响应体和业务数据模型 |
+| `service.employee` | 员工花名册同步、查询和钉钉人员数据处理 |
+| `service.port` | 明磊外部 T100 接口转发 |
+| `service.yida` | 宜搭流程、宜搭表单查询、ERP 推送分发 |
+| `service.impl.*` | 各业务实现、处理器和内部辅助类 |
+
+新增能力时优先放入现有业务域。只有出现清晰的新业务边界、独立生命周期或独立外部依赖时,才新增新的 `Service` 分类。
+
+## 3. 配置说明
+
+配置文件位于 `src/main/resources/application.yml`。生产环境不要使用文件中的默认示例值,应通过环境变量覆盖。
+
+| 配置项 | 环境变量 | 用途 |
+| --- | --- | --- |
+| `server.port` | `SERVER_PORT` | HTTP 服务端口 |
+| `spring.profiles.active` | `SPRING_PROFILES_ACTIVE` | Spring Profile |
+| `dingtalk.agent-id` | `DINGTALK_AGENT_ID` | 钉钉应用 AgentId |
+| `dingtalk.app-key` | `DINGTALK_APP_KEY` | 钉钉应用 Key |
+| `dingtalk.app-secret` | `DINGTALK_APP_SECRET` | 钉钉应用 Secret |
+| `minglei.yida.coordination-office.app-type` | `YIDA_COORDINATION_OFFICE_APP_TYPE` | 协同办公宜搭应用编码 |
+| `minglei.yida.coordination-office.system-token` | `YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN` | 协同办公宜搭应用 System Token |
+| `minglei.yida.master-data.app-type` | `YIDA_MASTER_DATA_APP_TYPE` | 主数据宜搭应用编码 |
+| `minglei.yida.master-data.system-token` | `YIDA_MASTER_DATA_SYSTEM_TOKEN` | 主数据宜搭应用 System Token |
+| `minglei.port.supplier-price-approval-url` | `MINGLEI_SUPPLIER_PRICE_APPROVAL_URL` | 外部 T100 接口地址 |
+| `minglei.port.supplier-price-approval-key` | `MINGLEI_SUPPLIER_PRICE_APPROVAL_KEY` | 外部 T100 接口接入密钥 |
+| `minglei.employee-roster.datasource.url` | `MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_URL` | 员工花名册 MySQL JDBC 地址 |
+| `minglei.employee-roster.datasource.username` | `MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_USERNAME` | 员工花名册数据库用户名 |
+| `minglei.employee-roster.datasource.password` | `MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_PASSWORD` | 员工花名册数据库密码 |
+| `minglei.employee-roster.datasource.driver-class-name` | `MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_DRIVER_CLASS_NAME` | JDBC 驱动类 |
+| `logging.file.path` | `LOGGING_FILE_PATH` | 日志输出目录 |
+
+`MjavaYidaConfiguration` 注册两套宜搭客户端:
+
+| Bean | 用途 |
+| --- | --- |
+| `coordinationOfficeYidaClient` | 协同办公应用,主要用于发起流程 |
+| `masterDataYidaClient` | 主数据应用,主要用于查询主数据表单 |
+
+## 4. REST 接口
+
+### 4.1 宜搭流程发起
+
+```http
+POST /api/yida/process-instances
+Content-Type: application/json
+```
+
+请求示例:
+
+```json
+{
+  "type": "cpmp402",
+  "userId": "41603502201288023",
+  "dataTitle": "包材核价单",
+  "fields": {
+    "mainData": {
+      "rq": "2026-09-08",
+      "bm": "ML07000000",
+      "tjr": "3075",
+      "gs": "ML",
+      "t100id": "ML",
+      "ent": "87",
+      "t100bm": "ML07000000"
+    },
+    "detailData": {
+      "detailData": [
+        {
+          "hjdh": "MLCH326090800001",
+          "gysbh": "113009",
+          "gysmc": "供应商名称",
+          "t100id": "ML",
+          "ent": "87",
+          "yy": "zh_CN"
+        }
+      ]
+    }
+  }
+}
+```
+
+成功响应为 HTTP `201 Created`:
+
+```json
+{
+  "code": 200,
+  "isSuccess": true,
+  "result": {
+    "processInstanceId": "process-instance-id"
+  }
+}
+```
+
+### 4.2 宜搭主数据表单查询
+
+分页查询:
+
+```http
+GET /api/yida/minglei/forms/instances?page=1&size=20
+```
+
+精确查询:
+
+```http
+POST /api/yida/minglei/forms/instances/query
+Content-Type: application/json
+```
+
+请求示例:
+
+```json
+{
+  "dept_id": "123456",
+  "user_id": "dingTalkUserId",
+  "dept_name": "明磊锂能技术部"
+}
+```
+
+精确查询优先级为 `dept_id`、`user_id`、`dept_name`。传入 `user_id` 时,服务端会先查询人员所属部门,再按部门 ID 查询主数据。
+
+### 4.3 部门成员查询
+
+```http
+POST /api/yida/minglei/departments/users
+Content-Type: application/json
+```
+
+请求示例:
+
+```json
+{
+  "dept_Id": "123456",
+  "type": 0
+}
+```
+
+`type` 取值:
+
+| 值 | 说明 |
+| --- | --- |
+| `0` | 查询指定部门和所有子部门 |
+| `1` | 仅查询指定部门 |
+
+### 4.4 员工花名册
+
+同步指定部门:
+
+```http
+POST /api/yida/minglei/employee-roster/sync?departmentId=1&type=0
+```
+
+全量同步:
+
+```http
+POST /api/yida/minglei/employee-roster/sync-full
+```
+
+按钉钉用户 ID 查询花名册:
+
+```http
+POST /api/yida/minglei/employee-roster/getUserRoster
+Content-Type: application/json
+```
+
+### 4.5 外部 T100 接口转发
+
+```http
+POST /api/minglei/ports/supplier-price-approvals
+Content-Type: application/json
+```
+
+请求体只需要提供 `head` 和 `detail`,服务端会补齐外部接口协议固定字段。
+
+```json
+{
+  "head": {
+    "pmdidocno": "CH3",
+    "pmdi001": "N",
+    "pmdi034": "605188",
+    "pmdidocdt": "2026-08-06"
+  },
+  "detail": [
+    {
+      "pmdj011": "0.5000",
+      "pmdj002": "524000059517022000000"
+    }
+  ]
+}
+```
+
+### 4.6 ERP 推送宜搭
+
+```http
+POST /api/erp/yida/push
+Content-Type: application/json
+```
+
+请求示例:
+
+```json
+{
+  "type": "axmt140",
+  "mainData": {
+    "rq": "2026-09-08",
+    "bm": "123456",
+    "tjr": "3075",
+    "gs": "ML"
+  },
+  "detailData": {
+    "detailData1": [
+      {
+        "xmfyseq": "10",
+        "xmfy002": "DN-001",
+        "xmfy003": "2026-09-08",
+        "xmfy004": "CUS-001",
+        "pmaal004": "客户名称"
+      }
+    ]
+  }
+}
+```
+
+成功响应使用 `McR.success()`:
+
+```json
+{
+  "success": true,
+  "code": "200",
+  "message": "SUCCESS"
+}
+```
+
+## 5. ERP 推送规则
+
+### 5.1 支持的类型
+
+`type` 会去除首尾空格并转为大写后匹配,调用方传入大小写不敏感。
+
+| `type` 或别名 | 处理器 | 单据或流程 | 表单 UUID | 流程编码 |
+| --- | --- | --- | --- | --- |
+| `cpmp402` | `Cpmp402YidaProcessHandler` | 包材核价单 | `FORM-A3D4889187E54697A59590BE54F350E7AOO5` | `TPROC--98G66QB1NTF8E1R1LI0OK4D6JI9437HC92YSM1` |
+| `FORM-4403B45A7F184CB79088FCDAFD2FD925LZ3K` | `MlWorkOrderCraftPriceFormPushHandler` | ML-工单工艺工价审定 | `FORM-4403B45A7F184CB79088FCDAFD2FD925LZ3K` | `TPROC--WG966BA1VWF8Q1K4OGF8H9KW47RR3ZREH0YSM2` |
+| `axmt500` | `ApproveOrderFormPushHandler` | 客户订单审批 | `FORM-8454FA17732746058A2D90B1C559CC19G5SR` | `TPROC--9VD66QA18AJ8317KK3NXM54W1HIT30C7N28TM7` |
+| `axmt140` | `CustomerReleaseFormPushHandler` | 客户放行审批 | `FORM-3BA9FE18494F4FDD85FE6ABF7DE741BAE30C` | `TPROC--6TG66381N8G8Y7L9GBEEY7QVXTUY2LBBGZ7TMU` |
+| `cint301` | `MiscReceiptIssueFormPushHandler` | 杂收杂发审批 | `FORM-4B08ADABCDAA45788C043729A855E03ED76G` | `TPROC--MQD66371C1N84FPSJZO4W79IN4BW2AE7I08TM0` |
+
+### 5.2 `mainData` 规则
+
+| 字段 | 说明 |
+| --- | --- |
+| `rq` | 申请日期,通常为 `yyyy-MM-dd` 或毫秒时间戳 |
+| `bm` | 钉钉部门 ID |
+| `tjr` | 提交人工号,用于查询员工花名册并解析钉钉 `userId` |
+| `gs` | 公司或组织编码 |
+| `t100id` | T100 业务标识 |
+| `ent` | 地区或企业编号 |
+| `t100bm` | T100 部门 |
+
+顶层 `bm`、`gs`、`tjr` 非空时,会覆盖写入 `mainData`。服务端会将 `mainData` 中的通用字段补入各明细行,便于表单处理器复用。
+
+### 5.3 `detailData` 规则
+
+`detailData` 外层必须是对象,内部至少包含一个数组字段。数组字段名支持:
+
+- `detailData`
+- `detailData1`
+- `detailData2`
+- 其他满足 `detailData\d*` 格式的字段
+
+不同单据的约定:
+
+| 类型 | 明细字段 |
+| --- | --- |
+| `cpmp402` | `detailData` |
+| `FORM-4403B45A7F184CB79088FCDAFD2FD925LZ3K` | `detailData` |
+| `axmt500` | `detailData1`、`detailData2`,兼容历史 `detailData` |
+| `axmt140` | 优先 `detailData1`,缺失时回退到 `detailData` |
+| `cint301` | `detailData1` |
+
+明细对象中可以直接传入宜搭 `fieldId`。服务端会保留包含 `Field_` 的字段值,用于补充默认字段映射。
+
+### 5.4 发起人解析
+
+1. `ErpYidaPushServiceImpl` 优先使用顶层 `tjr`,其次使用 `mainData.tjr`。
+2. 服务端通过员工花名册查询工号对应的 `dingtalkId`。
+3. `dingtalkId` 会作为 `originatorUserId`、`userId` 和 `applicant` 写入请求上下文或明细行。
+4. 匹配到 `YidaFormPushHandler` 时,提交人工号允许为空,具体处理器可以使用默认发起人兜底。
+5. 未匹配到表单处理器并走通用 `YidaProcessService` 时,提交人工号不能为空。
+
+## 6. 日志和异常
+
+### 6.1 日志
+
+Logback 输出:
+
+- 控制台日志。
+- `${LOGGING_FILE_PATH}/application.log`。
+- `${LOGGING_FILE_PATH}/error.log`。
+- `archive` 目录下的滚动归档日志。
+
+`HttpRequestResponseLoggingFilter` 会记录 HTTP 方法、URI、状态码、耗时、请求体和响应体。单个载荷最大记录 10KB,并对 `password`、`secret`、`token`、`authorization`、`accessToken`、`systemToken`、`key` 等字段脱敏。
+
+### 6.2 异常响应
+
+| 场景 | HTTP 状态码 | 说明 |
+| --- | --- | --- |
+| 请求体格式错误或参数校验失败 | `400` | 请求不合法 |
+| 配置缺失 | `412` | 必要配置未提供 |
+| 宜搭或钉钉上游业务错误 | `502` | 返回上游错误码和错误信息 |
+| 外部 T100 调用失败 | `502` | 返回 `MINGLEI_PORT_CALL_FAILED` |
+
+## 7. 构建和运行
+
+环境要求:
+
+- JDK 8
+- Maven 3.6+
+- 可访问项目依赖仓库和内部 `malk` 依赖
+
+本地构建:
+
+```powershell
+mvn clean package
+```
+
+本地运行:
+
+```powershell
+$env:SERVER_PORT = "8080"
+$env:DINGTALK_APP_KEY = "<dingTalk-app-key>"
+$env:DINGTALK_APP_SECRET = "<dingTalk-app-secret>"
+$env:YIDA_COORDINATION_OFFICE_APP_TYPE = "<coordination-office-app-type>"
+$env:YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN = "<coordination-office-system-token>"
+$env:YIDA_MASTER_DATA_APP_TYPE = "<master-data-app-type>"
+$env:YIDA_MASTER_DATA_SYSTEM_TOKEN = "<master-data-system-token>"
+
+mvn spring-boot:run
+```
+
+运行打包产物:
+
+```powershell
+java -jar target\mjava-minglei-1.0.0-SNAPSHOT.jar
+```
+
+## 8. 测试
+
+执行完整测试:
+
+```powershell
+mvn clean test
+```
+
+当前测试覆盖:
+
+- Spring Boot 上下文加载。
+- Controller 请求和响应结构。
+- 员工花名册同步、查询和字段提取。
+- 宜搭流程处理器分发。
+- ERP 推送到不同宜搭表单处理器的字段映射。
+- 外部 T100 请求组装和响应转发。
+
+当前验证结果:
+
+```text
+Tests run: 30, Failures: 0, Errors: 0, Skipped: 1
+```
+
+## 9. 扩展规范
+
+### 9.1 新增宜搭流程处理器
+
+新增可由 `/api/yida/process-instances` 触发的通用流程时:
+
+1. 新建 `XxxYidaProcessHandler`。
+2. 实现 `YidaProcessHandler`。
+3. 在 `getType()` 返回业务路由编码。
+4. 在处理器中维护表单 UUID、流程编码和字段映射。
+5. 补充对应单元测试。
+6. 更新 README 的支持类型说明。
+
+### 9.2 新增 ERP 表单推送处理器
+
+新增可由 `/api/erp/yida/push` 路由的表单流程时:
+
+1. 新建 `XxxFormPushHandler`。
+2. 实现 `YidaFormPushHandler`。
+3. 在 `getTypes()` 返回主 `type` 和必要别名。
+4. 在处理器中维护表单 UUID、流程编码和字段映射。
+5. 在 `src/test/java` 中补充字段映射和异常测试。
+6. 更新 README 的 ERP 推送类型表。
+
+### 9.3 字段映射原则
+
+- 业务 `type` 是路由键,不等于表单 UUID。
+- 应用凭证、表单 UUID、流程编码由服务端维护,调用方不传。
+- 表单处理器优先使用 ERP 原始字段名映射。
+- 调用方确需补充时,可以传入宜搭 `fieldId` 字段。
+- 不要在日志、文档或测试数据中写入真实密钥、Token、Cookie 或数据库密码。
+
+## 10. 运维注意事项
+
+- 部署账号必须对 `LOGGING_FILE_PATH` 有写权限。
+- 宜搭和钉钉接口所需的网络连通性、权限范围、组织数据权限由部署环境保证。
+- 当前 Controller 未内置业务鉴权。如需访问控制,应在网关或统一认证层配置。
+- 发生宜搭配置不匹配时,确认应用编码、System Token 和处理器选择的客户端属于同一应用。
+- 员工花名册依赖数据库连接和数据完整性。若 `mainData.tjr` 无法解析,优先检查花名册同步状态和员工 `dingtalkId`。

+ 85 - 0
pom.xml

@@ -0,0 +1,85 @@
+<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.7.18</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.malk</groupId>
+    <artifactId>mjava-minglei</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+    <name>mjava-minglei</name>
+    <description>Yida process integration based on mjava</description>
+
+    <properties>
+        <java.version>1.8</java.version>
+        <maven.compiler.source>1.8</maven.compiler.source>
+        <maven.compiler.target>1.8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>0.0.3</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.springframework.boot</groupId>
+                    <artifactId>spring-boot-starter-data-jpa</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.springframework.boot</groupId>
+                    <artifactId>spring-boot-starter-jdbc</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>mysql</groupId>
+                    <artifactId>mysql-connector-java</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-jdbc</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <version>8.0.33</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+            <version>1.2.83</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 14 - 0
src/main/java/com/malk/minglei/MjavaMingleiApplication.java

@@ -0,0 +1,14 @@
+package com.malk.minglei;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@EnableScheduling
+@SpringBootApplication
+public class MjavaMingleiApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(MjavaMingleiApplication.class, args);
+    }
+}

+ 18 - 0
src/main/java/com/malk/minglei/config/DingTalkRosterConfiguration.java

@@ -0,0 +1,18 @@
+package com.malk.minglei.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class DingTalkRosterConfiguration {
+
+    @Bean("dingTalkRestTemplate")
+    public RestTemplate dingTalkRestTemplate() {
+        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
+        requestFactory.setConnectTimeout(10 * 1000);
+        requestFactory.setReadTimeout(60 * 1000);
+        return new RestTemplate(requestFactory);
+    }
+}

+ 45 - 0
src/main/java/com/malk/minglei/config/EmployeeRosterConfiguration.java

@@ -0,0 +1,45 @@
+package com.malk.minglei.config;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+
+import javax.sql.DataSource;
+
+@Configuration
+@EnableConfigurationProperties(EmployeeRosterProperties.class)
+public class EmployeeRosterConfiguration {
+
+    @Bean("employeeRosterDataSource")
+    @ConditionalOnExpression("'${minglei.employee-roster.datasource.url:}' != ''")
+    public DataSource employeeRosterDataSource(EmployeeRosterProperties properties) {
+        EmployeeRosterProperties.DataSourceProperties datasource = properties.getDatasource();
+        DriverManagerDataSource dataSource = new DriverManagerDataSource();
+        dataSource.setUrl(datasource.getUrl());
+        if (hasText(datasource.getDriverClassName())) {
+            dataSource.setDriverClassName(datasource.getDriverClassName());
+        }
+        if (hasText(datasource.getUsername())) {
+            dataSource.setUsername(datasource.getUsername());
+        }
+        if (datasource.getPassword() != null) {
+            dataSource.setPassword(datasource.getPassword());
+        }
+        return dataSource;
+    }
+
+    @Bean("employeeRosterJdbcTemplate")
+    @ConditionalOnBean(name = "employeeRosterDataSource")
+    public JdbcTemplate employeeRosterJdbcTemplate(@Qualifier("employeeRosterDataSource") DataSource dataSource) {
+        return new JdbcTemplate(dataSource);
+    }
+
+    private boolean hasText(String value) {
+        return value != null && !value.trim().isEmpty();
+    }
+}

+ 106 - 0
src/main/java/com/malk/minglei/config/EmployeeRosterProperties.java

@@ -0,0 +1,106 @@
+package com.malk.minglei.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "minglei.employee-roster")
+public class EmployeeRosterProperties {
+
+    private static final long DEFAULT_DEPARTMENT_ID = 1L;
+    private static final int DEFAULT_USER_PAGE_SIZE = 100;
+    private static final int DEFAULT_ROSTER_BATCH_SIZE = 20;
+
+    private long departmentId = DEFAULT_DEPARTMENT_ID;
+    private boolean includeSubDepartments = true;
+    private int userPageSize = DEFAULT_USER_PAGE_SIZE;
+    private int rosterBatchSize = DEFAULT_ROSTER_BATCH_SIZE;
+    private String tableName = "employee_roster";
+    private DataSourceProperties datasource = new DataSourceProperties();
+
+    public long getDepartmentId() {
+        return departmentId;
+    }
+
+    public void setDepartmentId(long departmentId) {
+        this.departmentId = departmentId;
+    }
+
+    public boolean isIncludeSubDepartments() {
+        return includeSubDepartments;
+    }
+
+    public void setIncludeSubDepartments(boolean includeSubDepartments) {
+        this.includeSubDepartments = includeSubDepartments;
+    }
+
+    public int getUserPageSize() {
+        return userPageSize;
+    }
+
+    public void setUserPageSize(int userPageSize) {
+        this.userPageSize = userPageSize;
+    }
+
+    public int getRosterBatchSize() {
+        return rosterBatchSize;
+    }
+
+    public void setRosterBatchSize(int rosterBatchSize) {
+        this.rosterBatchSize = rosterBatchSize;
+    }
+
+    public String getTableName() {
+        return tableName;
+    }
+
+    public void setTableName(String tableName) {
+        this.tableName = tableName;
+    }
+
+    public DataSourceProperties getDatasource() {
+        return datasource;
+    }
+
+    public void setDatasource(DataSourceProperties datasource) {
+        this.datasource = datasource;
+    }
+
+    public static class DataSourceProperties {
+
+        private String url;
+        private String username;
+        private String password;
+        private String driverClassName;
+
+        public String getUrl() {
+            return url;
+        }
+
+        public void setUrl(String url) {
+            this.url = url;
+        }
+
+        public String getUsername() {
+            return username;
+        }
+
+        public void setUsername(String username) {
+            this.username = username;
+        }
+
+        public String getPassword() {
+            return password;
+        }
+
+        public void setPassword(String password) {
+            this.password = password;
+        }
+
+        public String getDriverClassName() {
+            return driverClassName;
+        }
+
+        public void setDriverClassName(String driverClassName) {
+            this.driverClassName = driverClassName;
+        }
+    }
+}

+ 99 - 0
src/main/java/com/malk/minglei/config/HttpRequestResponseLoggingFilter.java

@@ -0,0 +1,99 @@
+package com.malk.minglei.config;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.regex.Pattern;
+
+/**
+ * Records HTTP request and response data to support API troubleshooting.
+ */
+@Component
+public class HttpRequestResponseLoggingFilter extends OncePerRequestFilter {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(HttpRequestResponseLoggingFilter.class);
+
+    private static final int MAX_PAYLOAD_LENGTH = 10 * 1024;
+    private static final Pattern SENSITIVE_JSON_FIELD = Pattern.compile(
+            "(\\\"(?:password|secret|token|authorization|accessToken|systemToken|key)\\\"\\s*:\\s*\\\")[^\\\"]*(\\\")",
+            Pattern.CASE_INSENSITIVE);
+    private static final Pattern SENSITIVE_QUERY_PARAMETER = Pattern.compile(
+            "((?:password|secret|token|authorization|accessToken|systemToken|key)=)[^&]*",
+            Pattern.CASE_INSENSITIVE);
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        String contentType = request.getContentType();
+        return contentType != null && (contentType.startsWith("multipart/")
+                || contentType.startsWith("application/octet-stream"));
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws ServletException, IOException {
+        ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request, MAX_PAYLOAD_LENGTH);
+        ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
+        long startTime = System.currentTimeMillis();
+
+        try {
+            filterChain.doFilter(requestWrapper, responseWrapper);
+        } finally {
+            long duration = System.currentTimeMillis() - startTime;
+            LOGGER.info(
+                    "HTTP request method={} uri={} query={} clientIp={} status={} durationMs={} requestBody={} responseBody={}",
+                    new Object[]{
+                            request.getMethod(),
+                            request.getRequestURI(),
+                            maskQueryString(request.getQueryString()),
+                            request.getRemoteAddr(),
+                            responseWrapper.getStatus(),
+                            duration,
+                            getPayload(requestWrapper.getContentAsByteArray(), requestWrapper.getCharacterEncoding()),
+                            getPayload(responseWrapper.getContentAsByteArray(), responseWrapper.getCharacterEncoding())
+                    });
+            responseWrapper.copyBodyToResponse();
+        }
+    }
+
+    private String getPayload(byte[] content, String characterEncoding) {
+        if (content.length == 0) {
+            return "";
+        }
+
+        int payloadLength = Math.min(content.length, MAX_PAYLOAD_LENGTH);
+        Charset charset = getCharset(characterEncoding);
+        String payload = new String(content, 0, payloadLength, charset).replaceAll("[\\r\\n\\t]+", " ");
+        String maskedPayload = SENSITIVE_JSON_FIELD.matcher(payload).replaceAll("$1***$2");
+        return content.length > MAX_PAYLOAD_LENGTH ? maskedPayload + "... [truncated]" : maskedPayload;
+    }
+
+    private Charset getCharset(String characterEncoding) {
+        if (characterEncoding == null) {
+            return StandardCharsets.UTF_8;
+        }
+
+        try {
+            return Charset.forName(characterEncoding);
+        } catch (RuntimeException exception) {
+            return StandardCharsets.UTF_8;
+        }
+    }
+
+    private String maskQueryString(String queryString) {
+        if (queryString == null) {
+            return null;
+        }
+        return SENSITIVE_QUERY_PARAMETER.matcher(queryString).replaceAll("$1***");
+    }
+}

+ 28 - 0
src/main/java/com/malk/minglei/config/MingleiPortConfiguration.java

@@ -0,0 +1,28 @@
+package com.malk.minglei.config;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * 配置调用明磊外部单据接口所需的 HTTP 客户端。
+ */
+@Configuration
+@EnableConfigurationProperties(MingleiPortProperties.class)
+public class MingleiPortConfiguration {
+
+    /**
+     * 创建专用于明磊外部接口的 HTTP 客户端,并限制连接与读取等待时间。
+     *
+     * @return 配置完成的 HTTP 客户端
+     */
+    @Bean("mingleiPortRestTemplate")
+    public RestTemplate mingleiPortRestTemplate() {
+        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
+        requestFactory.setConnectTimeout(10 * 1000);
+        requestFactory.setReadTimeout(30 * 1000);
+        return new RestTemplate(requestFactory);
+    }
+}

+ 29 - 0
src/main/java/com/malk/minglei/config/MingleiPortProperties.java

@@ -0,0 +1,29 @@
+package com.malk.minglei.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * 管理明磊外部单据接口的调用地址。
+ */
+@ConfigurationProperties(prefix = "minglei.port")
+public class MingleiPortProperties {
+
+    private String supplierPriceApprovalUrl;
+    private String supplierPriceApprovalKey;
+
+    public String getSupplierPriceApprovalUrl() {
+        return supplierPriceApprovalUrl;
+    }
+
+    public void setSupplierPriceApprovalUrl(String supplierPriceApprovalUrl) {
+        this.supplierPriceApprovalUrl = supplierPriceApprovalUrl;
+    }
+
+    public String getSupplierPriceApprovalKey() {
+        return supplierPriceApprovalKey;
+    }
+
+    public void setSupplierPriceApprovalKey(String supplierPriceApprovalKey) {
+        this.supplierPriceApprovalKey = supplierPriceApprovalKey;
+    }
+}

+ 75 - 0
src/main/java/com/malk/minglei/config/MjavaYidaConfiguration.java

@@ -0,0 +1,75 @@
+package com.malk.minglei.config;
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.aliwork.YDClient_Process;
+import com.malk.service.aliwork.impl.YDClientImpl;
+import com.malk.service.aliwork.impl.YDClient_ProcessImpl;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.impl.DDImplClient;
+import com.malk.service.dingtalk.impl.DDImplClient_Contacts;
+import com.malk.service.dingtalk.impl.DDImplClient_Personnel;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+
+import java.lang.reflect.Field;
+
+@Configuration
+@EnableConfigurationProperties({DDConf.class, YidaApplicationsProperties.class})
+@Import({DDImplClient.class, DDImplClient_Contacts.class, DDImplClient_Personnel.class})
+public class MjavaYidaConfiguration {
+
+    @Bean("coordinationOfficeYidaClient")
+    public YDClient coordinationOfficeYidaClient(DDClient dingTalkClient, YidaApplicationsProperties applications) {
+        return createYidaClient(dingTalkClient, applications.getCoordinationOffice());
+    }
+
+    @Bean("masterDataYidaClient")
+    public YDClient masterDataYidaClient(DDClient dingTalkClient, YidaApplicationsProperties applications) {
+        return createYidaClient(dingTalkClient, applications.getMasterData());
+    }
+
+    @Bean("coordinationOfficeYidaProcessClient")
+    public YDClient_Process coordinationOfficeYidaProcessClient(DDClient dingTalkClient) {
+        YDClient_ProcessImpl yidaProcessClient = new YDClient_ProcessImpl();
+        inject(yidaProcessClient, "ddClient", dingTalkClient);
+        return yidaProcessClient;
+    }
+
+    /**
+     * 为 malk 自动扫描到的默认客户端提供协调办公应用配置。
+     */
+    @Bean
+    public YDConf yidaConfiguration(YidaApplicationsProperties applications) {
+        return createYidaConfiguration(applications.getCoordinationOffice());
+    }
+
+    private YDClient createYidaClient(DDClient dingTalkClient, YidaApplicationsProperties.ApplicationCredentials credentials) {
+        YDConf yidaConfiguration = createYidaConfiguration(credentials);
+
+        YDClientImpl yidaClient = new YDClientImpl();
+        inject(yidaClient, "ydConf", yidaConfiguration);
+        inject(yidaClient, "ddClient", dingTalkClient);
+        return yidaClient;
+    }
+
+    private YDConf createYidaConfiguration(YidaApplicationsProperties.ApplicationCredentials credentials) {
+        YDConf yidaConfiguration = new YDConf();
+        yidaConfiguration.setAppType(credentials.getAppType());
+        yidaConfiguration.setSystemToken(credentials.getSystemToken());
+        return yidaConfiguration;
+    }
+
+    private void inject(Object target, String fieldName, Object value) {
+        try {
+            Field field = target.getClass().getDeclaredField(fieldName);
+            field.setAccessible(true);
+            field.set(target, value);
+        } catch (ReflectiveOperationException exception) {
+            throw new IllegalStateException("Unable to initialize malk Yida client", exception);
+        }
+    }
+}

+ 48 - 0
src/main/java/com/malk/minglei/config/YidaApplicationsProperties.java

@@ -0,0 +1,48 @@
+package com.malk.minglei.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "minglei.yida")
+public class YidaApplicationsProperties {
+
+    private ApplicationCredentials coordinationOffice = new ApplicationCredentials();
+    private ApplicationCredentials masterData = new ApplicationCredentials();
+
+    public ApplicationCredentials getCoordinationOffice() {
+        return coordinationOffice;
+    }
+
+    public void setCoordinationOffice(ApplicationCredentials coordinationOffice) {
+        this.coordinationOffice = coordinationOffice;
+    }
+
+    public ApplicationCredentials getMasterData() {
+        return masterData;
+    }
+
+    public void setMasterData(ApplicationCredentials masterData) {
+        this.masterData = masterData;
+    }
+
+    public static class ApplicationCredentials {
+
+        private String appType;
+        private String systemToken;
+
+        public String getAppType() {
+            return appType;
+        }
+
+        public void setAppType(String appType) {
+            this.appType = appType;
+        }
+
+        public String getSystemToken() {
+            return systemToken;
+        }
+
+        public void setSystemToken(String systemToken) {
+            this.systemToken = systemToken;
+        }
+    }
+}

+ 46 - 0
src/main/java/com/malk/minglei/controller/EmployeeRosterController.java

@@ -0,0 +1,46 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.dto.EmployeeRosterQueryRequest;
+import com.malk.minglei.dto.EmployeeRosterSyncResult;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import javax.validation.constraints.Max;
+import javax.validation.constraints.Min;
+
+@Validated
+@RestController
+@RequestMapping("/api/yida/minglei/employee-roster")
+public class EmployeeRosterController {
+
+    private final EmployeeRosterService employeeRosterService;
+
+    public EmployeeRosterController(EmployeeRosterService employeeRosterService) {
+        this.employeeRosterService = employeeRosterService;
+    }
+
+    @PostMapping("/sync")
+    public ResponseEntity<ApiResponse<EmployeeRosterSyncResult>> sync(@RequestParam(name = "departmentId", defaultValue = "1") @Min(1) long departmentId,
+                                                                      @RequestParam(name = "type", defaultValue = "0") @Min(0) @Max(1) int type) {
+        return ResponseEntity.ok(ApiResponse.success(employeeRosterService.sync(departmentId, type)));
+    }
+
+    @PostMapping("/sync-full")
+    public ResponseEntity<ApiResponse<EmployeeRosterSyncResult>> syncFull() {
+        return ResponseEntity.ok(ApiResponse.success(employeeRosterService.syncFull()));
+    }
+
+    @PostMapping("/getUserRoster")
+    public ResponseEntity<ApiResponse<EmployeeRosterInfo>> getUserRoster(@Valid @RequestBody EmployeeRosterQueryRequest request) {
+        return ResponseEntity.ok(ApiResponse.success(employeeRosterService.findByUserId(request.getUserID())));
+    }
+}

+ 43 - 0
src/main/java/com/malk/minglei/controller/ErpYidaPushController.java

@@ -0,0 +1,43 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.yida.ErpYidaPushService;
+import com.malk.server.common.McR;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+/**
+ * 接收 ERP 推送至宜搭的数据。
+ */
+@Validated
+@RestController
+@RequestMapping("/api/erp/yida")
+public class ErpYidaPushController {
+
+    private final ErpYidaPushService erpYidaPushService;
+
+    /**
+     * 创建 ERP 推送接口控制器。
+     *
+     * @param erpYidaPushService ERP 推送接收服务
+     */
+    public ErpYidaPushController(ErpYidaPushService erpYidaPushService) {
+        this.erpYidaPushService = erpYidaPushService;
+    }
+
+    /**
+     * 接收 ERP 单据数据并记录入参,当前阶段不调用宜搭。
+     *
+     * @param request ERP 推送请求
+     * @return 固定成功响应
+     */
+    @PostMapping("/push")
+    public McR push(@Valid @RequestBody ErpYidaPushRequest request) {
+        return erpYidaPushService.push(request);
+    }
+}

+ 62 - 0
src/main/java/com/malk/minglei/controller/GlobalExceptionHandler.java

@@ -0,0 +1,62 @@
+package com.malk.minglei.controller;
+
+import com.malk.server.common.McException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+    @ExceptionHandler(IllegalStateException.class)
+    public ResponseEntity<Map<String, String>> handleConfiguration(IllegalStateException exception) {
+        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED)
+                .body(Collections.singletonMap("message", exception.getMessage()));
+    }
+
+    @ExceptionHandler(IllegalArgumentException.class)
+    public ResponseEntity<Map<String, String>> handleInvalidArgument(IllegalArgumentException exception) {
+        return ResponseEntity.badRequest()
+                .body(Collections.singletonMap("message", defaultText(exception.getMessage(), "Request parameter is invalid")));
+    }
+
+    @ExceptionHandler({MethodArgumentNotValidException.class, HttpMessageNotReadableException.class})
+    public ResponseEntity<Map<String, String>> handleBadRequest(Exception exception) {
+        return ResponseEntity.badRequest().body(Collections.singletonMap("message", "Request validation failed"));
+    }
+
+    @ExceptionHandler(McException.class)
+    public ResponseEntity<Map<String, String>> handleYidaClient(McException exception) {
+        LOGGER.error("Yida client request failed code={} source={}", exception.getCode(), exception.getSource(), exception);
+
+        Map<String, String> response = new LinkedHashMap<String, String>();
+        response.put("code", defaultText(exception.getCode(), "YIDA_CALL_FAILED"));
+        response.put("message", defaultText(exception.getMessage(), "Yida client request failed"));
+        return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(response);
+    }
+
+    @ExceptionHandler(Exception.class)
+    public ResponseEntity<Map<String, String>> handleUnexpected(Exception exception) {
+        LOGGER.error("Yida client request failed", exception);
+
+        Map<String, String> response = new LinkedHashMap<String, String>();
+        response.put("code", "YIDA_CALL_FAILED");
+        response.put("message", "Yida client request failed. Check the application log for details.");
+        return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(response);
+    }
+
+    private String defaultText(String value, String fallback) {
+        return value == null || value.trim().isEmpty() ? fallback : value.trim();
+    }
+}

+ 75 - 0
src/main/java/com/malk/minglei/controller/MingleiPortController.java

@@ -0,0 +1,75 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.MingleiPortRequest;
+import com.malk.minglei.service.port.MingleiPortService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.client.RestClientException;
+
+import javax.validation.Valid;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 提供调用明磊外部单据接口的本地触发入口。
+ */
+@RestController
+@RequestMapping("/api/minglei/ports")
+public class MingleiPortController {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(MingleiPortController.class);
+
+    private final MingleiPortService mingleiPortService;
+
+    public MingleiPortController(MingleiPortService mingleiPortService) {
+        this.mingleiPortService = mingleiPortService;
+    }
+
+    /**
+     * 接收本系统的供应商价格审批单请求,并调用外部 T100 接口。
+     *
+     * @param request 仅包含 head 和 detail 的供应商价格审批单请求体
+     * @return 外部 T100 接口响应内容
+     */
+    @PostMapping("/supplier-price-approvals")
+    public ResponseEntity<Map<String, Object>> submitSupplierPriceApproval(@Valid @RequestBody MingleiPortRequest request) {
+        return ResponseEntity.ok(mingleiPortService.submitSupplierPriceApproval(request));
+    }
+
+    /**
+     * 返回本地请求体校验或 JSON 解析失败的信息,不发起外部调用。
+     *
+     * @param exception 请求处理异常
+     * @return 参数错误响应
+     */
+    @ExceptionHandler({MethodArgumentNotValidException.class, HttpMessageNotReadableException.class})
+    public ResponseEntity<Map<String, String>> handleInvalidRequest(Exception exception) {
+        return ResponseEntity.badRequest().body(Collections.singletonMap("message", "Request validation failed"));
+    }
+
+    /**
+     * 将外部服务的连接、超时或响应异常转换为本地网关错误。
+     *
+     * @param exception 外部 HTTP 调用异常
+     * @return 外部调用失败响应
+     */
+    @ExceptionHandler(RestClientException.class)
+    public ResponseEntity<Map<String, String>> handleExternalCallFailure(RestClientException exception) {
+        LOGGER.error("Supplier price approval external call failed", exception);
+
+        Map<String, String> response = new LinkedHashMap<String, String>();
+        response.put("code", "MINGLEI_PORT_CALL_FAILED");
+        response.put("message", "Supplier price approval external call failed. Check the application log for details.");
+        return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(response);
+    }
+}

+ 103 - 0
src/main/java/com/malk/minglei/controller/YidaProcessController.java

@@ -0,0 +1,103 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.MingleiDocumentQueryRequest;
+import com.malk.minglei.dto.DepartmentUserQueryRequest;
+import com.malk.minglei.dto.DepartmentUserIdsResponse;
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.YidaProcessStartRequest;
+import com.malk.minglei.service.yida.MingleiDocumentService;
+import com.malk.minglei.service.yida.YidaProcessService;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.constraints.Max;
+import javax.validation.constraints.Min;
+import javax.validation.Valid;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@Validated
+@RestController
+@RequestMapping("/api/yida")
+public class YidaProcessController {
+
+    private final YidaProcessService yidaProcessService;
+    private final MingleiDocumentService mingleiDocumentService;
+
+    public YidaProcessController(YidaProcessService yidaProcessService, MingleiDocumentService mingleiDocumentService) {
+        this.yidaProcessService = yidaProcessService;
+        this.mingleiDocumentService = mingleiDocumentService;
+    }
+
+
+    /**
+     * 发起宜搭流程,并按统一响应结构返回流程实例 ID。
+     *
+     * @param request 已通过字段校验的流程发起请求
+     * @return HTTP 201 状态及包含流程实例 ID 的统一响应
+     */
+    @PostMapping("/process-instances")
+    public ResponseEntity<ApiResponse<Map<String, String>>> start(@Valid @RequestBody YidaProcessStartRequest request) {
+        String processInstanceId = yidaProcessService.startProcess(request);
+        return ResponseEntity.status(HttpStatus.CREATED)
+                .body(ApiResponse.success(Collections.singletonMap("processInstanceId", processInstanceId)));
+    }
+
+    /**
+     * 返回流程类型不支持等本地请求参数错误。
+     *
+     * @param exception 参数异常
+     * @return 参数错误响应
+     */
+    @ExceptionHandler(IllegalArgumentException.class)
+    public ResponseEntity<Map<String, String>> handleInvalidArgument(IllegalArgumentException exception) {
+        return ResponseEntity.badRequest().body(Collections.singletonMap("message", exception.getMessage()));
+    }
+
+    /**
+     * 返回请求体缺少必填字段或字段格式不合法的错误信息。
+     *
+     * @param exception 请求体校验异常
+     * @return 参数错误响应
+     */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public ResponseEntity<Map<String, String>> handleValidationFailure(MethodArgumentNotValidException exception) {
+        return ResponseEntity.badRequest().body(Collections.singletonMap("message", "Request validation failed"));
+    }
+
+    @GetMapping("/minglei/forms/instances")
+    public ResponseEntity<Map<String, Object>> queryMingleiDocuments(@RequestParam(name = "page", defaultValue = "1") @Min(1) int page,
+                                                                      @RequestParam(name = "size", defaultValue = "20") @Min(1) @Max(100) int size) {
+        return ResponseEntity.ok(mingleiDocumentService.queryDocuments(page, size));
+    }
+
+    /**
+     * 按 type 指定的范围查询部门中的钉钉用户 ID。
+     *
+     * @param request 包含 dept_Id 和 type 的部门成员查询请求
+     * @return 去重后的钉钉 userid 列表
+     */
+    @PostMapping("/minglei/departments/users")
+    public ResponseEntity<DepartmentUserIdsResponse> listDepartmentUserIds(@Valid @RequestBody DepartmentUserQueryRequest request) {
+        List<String> userIds = mingleiDocumentService.listDepartmentUserIds(request.getDepartmentId(), request.getType());
+        return ResponseEntity.ok(DepartmentUserIdsResponse.success(userIds));
+    }
+
+    /**
+     * 按部门编号、用户编号或部门名称的优先级精准查询明磊表单单据。
+     */
+    @PostMapping("/minglei/forms/instances/query")
+    public ResponseEntity<Map<String, Object>> queryMingleiDocuments(@RequestBody MingleiDocumentQueryRequest request) {
+        return ResponseEntity.ok(mingleiDocumentService.queryDocuments(request));
+    }
+}

+ 43 - 0
src/main/java/com/malk/minglei/dto/ApiResponse.java

@@ -0,0 +1,43 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * 新增接口使用的统一成功响应模型。
+ *
+ * @param <T> 业务返回数据类型
+ */
+public class ApiResponse<T> {
+
+    private final int code;
+
+    @JsonProperty("isSuccess")
+    private final boolean success;
+
+    private final T result;
+
+    private ApiResponse(int code, boolean success, T result) {
+        this.code = code;
+        this.success = success;
+        this.result = result;
+    }
+
+    /**
+     * 创建 HTTP 和业务状态均为成功的统一响应。
+     *
+     * @param result 业务返回数据
+     * @param <T> 业务返回数据类型
+     * @return 包含 code、isSuccess、result 三个字段的响应
+     */
+    public static <T> ApiResponse<T> success(T result) {
+        return new ApiResponse<>(200, true, result);
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public T getResult() {
+        return result;
+    }
+}

+ 42 - 0
src/main/java/com/malk/minglei/dto/DepartmentUserIdsResponse.java

@@ -0,0 +1,42 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * 部门成员 ID 查询接口的标准响应模型。
+ */
+public class DepartmentUserIdsResponse {
+
+    private final int code;
+
+    @JsonProperty("isSuccess")
+    private final boolean success;
+
+    private final List<String> result;
+
+    private DepartmentUserIdsResponse(List<String> result) {
+        this.code = 200;
+        this.success = true;
+        this.result = result;
+    }
+
+    /**
+     * 创建查询成功响应。
+     *
+     * @param userIds 部门成员的钉钉 userid 列表
+     * @return 标准成功响应
+     */
+    public static DepartmentUserIdsResponse success(List<String> userIds) {
+        return new DepartmentUserIdsResponse(userIds);
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public List<String> getResult() {
+        return result;
+    }
+}

+ 51 - 0
src/main/java/com/malk/minglei/dto/DepartmentUserQueryRequest.java

@@ -0,0 +1,51 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Max;
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+
+/**
+ * 按部门查询钉钉成员的请求模型。
+ */
+public class DepartmentUserQueryRequest {
+
+    @NotBlank
+    @Pattern(regexp = "[1-9]\\d*", message = "dept_Id must be a positive integer")
+    @JsonProperty("dept_Id")
+    private String departmentId;
+
+    @NotNull
+    @Min(value = 0, message = "type must be 0 or 1")
+    @Max(value = 1, message = "type must be 0 or 1")
+    private Integer type;
+
+    public long getDepartmentId() {
+        return Long.parseLong(departmentId);
+    }
+
+    public void setDepartmentId(String departmentId) {
+        this.departmentId = departmentId;
+    }
+
+    /**
+     * 返回部门查询范围:0 表示包含子部门,1 表示仅当前部门。
+     *
+     * @return 部门查询范围标识
+     */
+    public int getType() {
+        return type;
+    }
+
+    /**
+     * 设置部门查询范围,由请求体中的 type 字段绑定。
+     *
+     * @param type 0 表示包含子部门,1 表示仅当前部门
+     */
+    public void setType(Integer type) {
+        this.type = type;
+    }
+}

+ 95 - 0
src/main/java/com/malk/minglei/dto/EmployeeRosterInfo.java

@@ -0,0 +1,95 @@
+package com.malk.minglei.dto;
+
+public class EmployeeRosterInfo {
+
+    private Integer id;
+    private String dingtalkId;
+    private String employeeNo;
+    private String username;
+    private String jobTitle;
+    private String jobTitleId;
+    private String jobGrade;
+    private String jobGradeId;
+    private String department;
+    private String firstDepartment;
+
+    public Integer getId() {
+        return id;
+    }
+
+    public void setId(Integer id) {
+        this.id = id;
+    }
+
+    public String getDingtalkId() {
+        return dingtalkId;
+    }
+
+    public void setDingtalkId(String dingtalkId) {
+        this.dingtalkId = dingtalkId;
+    }
+
+    public String getEmployeeNo() {
+        return employeeNo;
+    }
+
+    public void setEmployeeNo(String employeeNo) {
+        this.employeeNo = employeeNo;
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public String getJobTitle() {
+        return jobTitle;
+    }
+
+    public void setJobTitle(String jobTitle) {
+        this.jobTitle = jobTitle;
+    }
+
+    public String getJobTitleId() {
+        return jobTitleId;
+    }
+
+    public void setJobTitleId(String jobTitleId) {
+        this.jobTitleId = jobTitleId;
+    }
+
+    public String getJobGrade() {
+        return jobGrade;
+    }
+
+    public void setJobGrade(String jobGrade) {
+        this.jobGrade = jobGrade;
+    }
+
+    public String getJobGradeId() {
+        return jobGradeId;
+    }
+
+    public void setJobGradeId(String jobGradeId) {
+        this.jobGradeId = jobGradeId;
+    }
+
+    public String getDepartment() {
+        return department;
+    }
+
+    public void setDepartment(String department) {
+        this.department = department;
+    }
+
+    public String getFirstDepartment() {
+        return firstDepartment;
+    }
+
+    public void setFirstDepartment(String firstDepartment) {
+        this.firstDepartment = firstDepartment;
+    }
+}

+ 20 - 0
src/main/java/com/malk/minglei/dto/EmployeeRosterQueryRequest.java

@@ -0,0 +1,20 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import javax.validation.constraints.NotBlank;
+
+public class EmployeeRosterQueryRequest {
+
+    @NotBlank
+    @JsonProperty("userID")
+    private String userID;
+
+    public String getUserID() {
+        return userID;
+    }
+
+    public void setUserID(String userID) {
+        this.userID = userID;
+    }
+}

+ 38 - 0
src/main/java/com/malk/minglei/dto/EmployeeRosterSyncResult.java

@@ -0,0 +1,38 @@
+package com.malk.minglei.dto;
+
+public class EmployeeRosterSyncResult {
+
+    private final long departmentId;
+    private final boolean includeSubDepartments;
+    private final int userCount;
+    private final int fieldCount;
+    private final int syncedCount;
+
+    public EmployeeRosterSyncResult(long departmentId, boolean includeSubDepartments, int userCount, int fieldCount, int syncedCount) {
+        this.departmentId = departmentId;
+        this.includeSubDepartments = includeSubDepartments;
+        this.userCount = userCount;
+        this.fieldCount = fieldCount;
+        this.syncedCount = syncedCount;
+    }
+
+    public long getDepartmentId() {
+        return departmentId;
+    }
+
+    public boolean isIncludeSubDepartments() {
+        return includeSubDepartments;
+    }
+
+    public int getUserCount() {
+        return userCount;
+    }
+
+    public int getFieldCount() {
+        return fieldCount;
+    }
+
+    public int getSyncedCount() {
+        return syncedCount;
+    }
+}

+ 116 - 0
src/main/java/com/malk/minglei/dto/ErpYidaPushMainData.java

@@ -0,0 +1,116 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * ERP 推送宜搭时的固定主数据。
+ */
+public class ErpYidaPushMainData {
+
+    /**
+     * 日期。
+     */
+    private String rq;
+
+    /**
+     * 部门:钉钉部门编号。
+     */
+    private String bm;
+
+    /**
+     * 提交人:工号。服务端根据该工号查询钉钉人员 ID。
+     */
+    private String tjr;
+
+    /**
+     * 公司:代码。
+     */
+    private String gs;
+
+    /**
+     * T100 用户或业务对象标识。
+     */
+    private String t100id;
+
+    /**
+     * 地区代码。
+     */
+    private String ent;
+
+    /**
+     * T100 部门。
+     */
+    private String t100bm;
+
+    private final Map<String, Object> extraFields = new LinkedHashMap<String, Object>();
+
+    public String getRq() {
+        return rq;
+    }
+
+    public void setRq(String rq) {
+        this.rq = rq;
+    }
+
+    public String getBm() {
+        return bm;
+    }
+
+    public void setBm(String bm) {
+        this.bm = bm;
+    }
+
+    public String getTjr() {
+        return tjr;
+    }
+
+    public void setTjr(String tjr) {
+        this.tjr = tjr;
+    }
+
+    public String getGs() {
+        return gs;
+    }
+
+    public void setGs(String gs) {
+        this.gs = gs;
+    }
+
+    public String getT100id() {
+        return t100id;
+    }
+
+    public void setT100id(String t100id) {
+        this.t100id = t100id;
+    }
+
+    public String getEnt() {
+        return ent;
+    }
+
+    public void setEnt(String ent) {
+        this.ent = ent;
+    }
+
+    public String getT100bm() {
+        return t100bm;
+    }
+
+    public void setT100bm(String t100bm) {
+        this.t100bm = t100bm;
+    }
+
+    @JsonAnyGetter
+    public Map<String, Object> getExtraFields() {
+        return extraFields;
+    }
+
+    @JsonAnySetter
+    public void setExtraField(String key, Object value) {
+        extraFields.put(key, value);
+    }
+}

+ 90 - 0
src/main/java/com/malk/minglei/dto/ErpYidaPushRequest.java

@@ -0,0 +1,90 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+/**
+ * ERP 推送宜搭的统一请求模型。
+ */
+public class ErpYidaPushRequest {
+
+    @NotBlank
+    private String type;
+
+    private String bm;
+
+    private String gs;
+
+    private String tjr;
+
+    @Valid
+    @NotNull
+    private ErpYidaPushMainData mainData;
+
+    @NotNull
+    private Object detailData;
+
+    @JsonIgnore
+    private String originatorUserId;
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+
+    public String getBm() {
+        return bm;
+    }
+
+    public void setBm(String bm) {
+        this.bm = bm;
+    }
+
+    public String getGs() {
+        return gs;
+    }
+
+    public void setGs(String gs) {
+        this.gs = gs;
+    }
+
+    public String getTjr() {
+        return tjr;
+    }
+
+    public void setTjr(String tjr) {
+        this.tjr = tjr;
+    }
+
+    public ErpYidaPushMainData getMainData() {
+        return mainData;
+    }
+
+    public void setMainData(ErpYidaPushMainData mainData) {
+        this.mainData = mainData;
+    }
+
+    public Object getDetailData() {
+        return detailData;
+    }
+
+    public void setDetailData(Object detailData) {
+        this.detailData = detailData;
+    }
+
+    @JsonIgnore
+    public String getOriginatorUserId() {
+        return originatorUserId;
+    }
+
+    @JsonIgnore
+    public void setOriginatorUserId(String originatorUserId) {
+        this.originatorUserId = originatorUserId;
+    }
+}

+ 42 - 0
src/main/java/com/malk/minglei/dto/MingleiDocumentQueryRequest.java

@@ -0,0 +1,42 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * 明磊单据精准查询请求参数。
+ */
+public class MingleiDocumentQueryRequest {
+
+    @JsonProperty("user_id")
+    private String userId;
+
+    @JsonProperty("dept_id")
+    private String deptId;
+
+    @JsonProperty("dept_name")
+    private String deptName;
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public void setUserId(String userId) {
+        this.userId = userId;
+    }
+
+    public String getDeptId() {
+        return deptId;
+    }
+
+    public void setDeptId(String deptId) {
+        this.deptId = deptId;
+    }
+
+    public String getDeptName() {
+        return deptName;
+    }
+
+    public void setDeptName(String deptName) {
+        this.deptName = deptName;
+    }
+}

+ 86 - 0
src/main/java/com/malk/minglei/dto/MingleiPortRequest.java

@@ -0,0 +1,86 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+/**
+ * 本地触发供应商价格审批单(核价)外部调用的精简请求模型。
+ */
+@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
+public class MingleiPortRequest {
+
+    @Valid
+    @NotNull
+    private Head head;
+
+    @Valid
+    @NotNull
+    private List<Detail> detail;
+
+    public Head getHead() {
+        return head;
+    }
+
+    public List<Detail> getDetail() {
+        return detail;
+    }
+
+    public String getDocumentNumber() {
+        return head == null ? null : head.pmdidocno;
+    }
+
+    public int getDetailCount() {
+        return detail == null ? 0 : detail.size();
+    }
+
+    @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
+    public static class Head {
+
+        @NotBlank
+        private String pmdidocno;
+
+        @NotBlank
+        private String pmdi001;
+
+        @NotBlank
+        private String pmdi034;
+
+        @NotBlank
+        private String pmdidocdt;
+
+        @NotBlank
+        private String pmdi004;
+
+        @NotBlank
+        private String pmdi015;
+
+        @NotBlank
+        private String pmdi016;
+
+        @NotBlank
+        private String pmdi003;
+
+        @NotBlank
+        private String pmdi002;
+
+        @NotBlank
+        private String pmdiud001;
+
+        @NotBlank
+        private String pmdistus;
+    }
+
+    @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
+    public static class Detail {
+
+        private String pmdj011;
+
+        private String pmdj002;
+
+        private String pmdj030;
+    }
+}

+ 45 - 0
src/main/java/com/malk/minglei/dto/StartProcessRequest.java

@@ -0,0 +1,45 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class StartProcessRequest {
+
+    @NotBlank
+    @JsonAlias("originatorUserId")
+    private String userId;
+
+    @NotBlank
+    private String dataTitle;
+
+    @NotEmpty
+    private Map<String, Object> fields = new LinkedHashMap<String, Object>();
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public void setUserId(String userId) {
+        this.userId = userId;
+    }
+
+    public String getDataTitle() {
+        return dataTitle;
+    }
+
+    public void setDataTitle(String dataTitle) {
+        this.dataTitle = dataTitle;
+    }
+
+    public Map<String, Object> getFields() {
+        return fields;
+    }
+
+    public void setFields(Map<String, Object> fields) {
+        this.fields = fields;
+    }
+}

+ 25 - 0
src/main/java/com/malk/minglei/dto/YidaOperationParam.java

@@ -0,0 +1,25 @@
+package com.malk.minglei.dto;
+
+import com.malk.server.aliwork.YDParam;
+
+public class YidaOperationParam extends YDParam {
+
+    private String title;
+    private String dataTitle;
+
+    public String getTitle() {
+        return title;
+    }
+
+    public void setTitle(String title) {
+        this.title = title;
+    }
+
+    public String getDataTitle() {
+        return dataTitle;
+    }
+
+    public void setDataTitle(String dataTitle) {
+        this.dataTitle = dataTitle;
+    }
+}

+ 59 - 0
src/main/java/com/malk/minglei/dto/YidaProcessStartRequest.java

@@ -0,0 +1,59 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 宜搭统一流程发起接口的请求模型。
+ */
+public class YidaProcessStartRequest {
+
+    @NotBlank
+    private String type;
+
+    @NotBlank
+    @JsonAlias("originatorUserId")
+    private String userId;
+
+    @NotBlank
+    private String dataTitle;
+
+    @NotEmpty
+    private Map<String, Object> fields = new LinkedHashMap<String, Object>();
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public void setUserId(String userId) {
+        this.userId = userId;
+    }
+
+    public String getDataTitle() {
+        return dataTitle;
+    }
+
+    public void setDataTitle(String dataTitle) {
+        this.dataTitle = dataTitle;
+    }
+
+    public Map<String, Object> getFields() {
+        return fields;
+    }
+
+    public void setFields(Map<String, Object> fields) {
+        this.fields = fields;
+    }
+}

+ 11 - 0
src/main/java/com/malk/minglei/service/employee/DingTalkEmployeeRosterClient.java

@@ -0,0 +1,11 @@
+package com.malk.minglei.service.employee;
+
+import java.util.List;
+import java.util.Map;
+
+public interface DingTalkEmployeeRosterClient {
+
+    List<Map> listRosterFieldGroups(String accessToken, Number agentId);
+
+    List<Map> listEmployeeRoster(String accessToken, List<String> userIds, Number agentId, List<String> fieldCodes);
+}

+ 15 - 0
src/main/java/com/malk/minglei/service/employee/EmployeeRosterService.java

@@ -0,0 +1,15 @@
+package com.malk.minglei.service.employee;
+
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.dto.EmployeeRosterSyncResult;
+
+public interface EmployeeRosterService {
+
+    EmployeeRosterSyncResult sync(long departmentId, int type);
+
+    EmployeeRosterSyncResult syncFull();
+
+    EmployeeRosterInfo findByUserId(String userId);
+
+    EmployeeRosterInfo findByEmployeeNo(String employeeNo);
+}

+ 123 - 0
src/main/java/com/malk/minglei/service/impl/employee/DingTalkEmployeeRosterClientImpl.java

@@ -0,0 +1,123 @@
+package com.malk.minglei.service.impl.employee;
+
+import com.malk.minglei.service.employee.DingTalkEmployeeRosterClient;
+import com.malk.service.dingtalk.DDClient_Personnel;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class DingTalkEmployeeRosterClientImpl implements DingTalkEmployeeRosterClient {
+
+    private static final String FIELD_GROUP_LIST_URL =
+            "https://oapi.dingtalk.com/topapi/smartwork/hrm/employee/field/grouplist?access_token={accessToken}";
+
+    private final RestTemplate restTemplate;
+    private final DDClient_Personnel personnelClient;
+
+    public DingTalkEmployeeRosterClientImpl(@Qualifier("dingTalkRestTemplate") RestTemplate restTemplate,
+                                            DDClient_Personnel personnelClient) {
+        this.restTemplate = restTemplate;
+        this.personnelClient = personnelClient;
+    }
+
+    @Override
+    public List<Map> listRosterFieldGroups(String accessToken, Number agentId) {
+        Map<String, Object> requestBody = new LinkedHashMap<String, Object>();
+        requestBody.put("agentid", agentId);
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
+
+        ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
+                FIELD_GROUP_LIST_URL,
+                HttpMethod.POST,
+                new HttpEntity<Map<String, Object>>(requestBody, headers),
+                new ParameterizedTypeReference<Map<String, Object>>() {
+                },
+                accessToken);
+
+        Map<String, Object> responseBody = response.getBody();
+        assertDingTalkSuccess(responseBody);
+        Object result = responseBody == null ? null : responseBody.get("result");
+        return toMapList(extractGroupList(result));
+    }
+
+    @Override
+    public List<Map> listEmployeeRoster(String accessToken, List<String> userIds, Number agentId, List<String> fieldCodes) {
+        if (userIds == null || userIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Map> result = personnelClient.getEmployeeInfos(accessToken, userIds, agentId, fieldCodes);
+        return result == null ? Collections.<Map>emptyList() : result;
+    }
+
+    private void assertDingTalkSuccess(Map<String, Object> responseBody) {
+        if (responseBody == null) {
+            throw new IllegalStateException("DingTalk roster field group response is empty");
+        }
+        Object errcode = responseBody.get("errcode");
+        Object success = responseBody.get("success");
+        if (errcode != null && !"0".equals(String.valueOf(errcode))) {
+            throw new IllegalStateException("DingTalk roster field group request failed: " + responseBody.get("errmsg"));
+        }
+        if (Boolean.FALSE.equals(success)) {
+            throw new IllegalStateException("DingTalk roster field group request failed: " + responseBody.get("errmsg"));
+        }
+    }
+
+    private Object extractGroupList(Object result) {
+        if (result instanceof List) {
+            return result;
+        }
+        if (!(result instanceof Map)) {
+            return Collections.emptyList();
+        }
+        Map resultMap = (Map) result;
+        Object groups = firstNotNull(
+                resultMap.get("group_list"),
+                resultMap.get("field_group_list"),
+                resultMap.get("groups"),
+                resultMap.get("list"));
+        return groups == null ? Collections.emptyList() : groups;
+    }
+
+    private List<Map> toMapList(Object value) {
+        if (!(value instanceof Iterable)) {
+            return Collections.emptyList();
+        }
+        List<Map> items = new ArrayList<Map>();
+        for (Object item : (Iterable<?>) value) {
+            if (item instanceof Map) {
+                items.add((Map) item);
+            }
+        }
+        return items;
+    }
+
+    private Object firstNotNull(Object first, Object second, Object third, Object fourth) {
+        if (first != null) {
+            return first;
+        }
+        if (second != null) {
+            return second;
+        }
+        if (third != null) {
+            return third;
+        }
+        return fourth;
+    }
+}

+ 86 - 0
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRecord.java

@@ -0,0 +1,86 @@
+package com.malk.minglei.service.impl.employee;
+
+public class EmployeeRosterRecord {
+
+    private String dingtalkId;
+    private String employeeNo;
+    private String username;
+    private String jobTitle;
+    private String jobTitleId;
+    private String jobGrade;
+    private String jobGradeId;
+    private String department;
+    private String firstDepartment;
+
+    public String getDingtalkId() {
+        return dingtalkId;
+    }
+
+    public void setDingtalkId(String dingtalkId) {
+        this.dingtalkId = dingtalkId;
+    }
+
+    public String getEmployeeNo() {
+        return employeeNo;
+    }
+
+    public void setEmployeeNo(String employeeNo) {
+        this.employeeNo = employeeNo;
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public String getJobTitle() {
+        return jobTitle;
+    }
+
+    public void setJobTitle(String jobTitle) {
+        this.jobTitle = jobTitle;
+    }
+
+    public String getJobTitleId() {
+        return jobTitleId;
+    }
+
+    public void setJobTitleId(String jobTitleId) {
+        this.jobTitleId = jobTitleId;
+    }
+
+    public String getJobGrade() {
+        return jobGrade;
+    }
+
+    public void setJobGrade(String jobGrade) {
+        this.jobGrade = jobGrade;
+    }
+
+    public String getJobGradeId() {
+        return jobGradeId;
+    }
+
+    public void setJobGradeId(String jobGradeId) {
+        this.jobGradeId = jobGradeId;
+    }
+
+    public String getDepartment() {
+        return department;
+    }
+
+    public void setDepartment(String department) {
+        this.department = department;
+    }
+
+    public String getFirstDepartment() {
+        return firstDepartment;
+    }
+
+    public void setFirstDepartment(String firstDepartment) {
+        this.firstDepartment = firstDepartment;
+    }
+}

+ 116 - 0
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepository.java

@@ -0,0 +1,116 @@
+package com.malk.minglei.service.impl.employee;
+
+import com.malk.minglei.config.EmployeeRosterProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+
+@Repository
+public class EmployeeRosterRepository {
+
+    private final ObjectProvider<JdbcTemplate> jdbcTemplateProvider;
+    private final EmployeeRosterProperties properties;
+
+    public EmployeeRosterRepository(@Qualifier("employeeRosterJdbcTemplate") ObjectProvider<JdbcTemplate> jdbcTemplateProvider,
+                                    EmployeeRosterProperties properties) {
+        this.jdbcTemplateProvider = jdbcTemplateProvider;
+        this.properties = properties;
+    }
+
+    public int upsertAll(List<EmployeeRosterRecord> records) {
+        if (records == null || records.isEmpty()) {
+            return 0;
+        }
+        JdbcTemplate jdbcTemplate = requireJdbcTemplate();
+
+        String sql = createMergeSql(resolveTableName());
+        for (EmployeeRosterRecord record : records) {
+            jdbcTemplate.update(sql, preparedStatement -> bind(preparedStatement, record));
+        }
+        return records.size();
+    }
+
+    public EmployeeRosterInfo findByDingtalkId(String dingtalkId) {
+        return findOne("dingtalk_id", dingtalkId);
+    }
+
+    public EmployeeRosterInfo findByEmployeeNo(String employeeNo) {
+        return findOne("employee_no", employeeNo);
+    }
+
+    private EmployeeRosterInfo findOne(String columnName, String value) {
+        JdbcTemplate jdbcTemplate = requireJdbcTemplate();
+        String sql = "SELECT id, dingtalk_id, employee_no, username, job_title, job_title_id, job_grade, job_grade_id, department, first_department " +
+                "FROM " + resolveTableName() + " WHERE " + columnName + " = ? LIMIT 1";
+        List<EmployeeRosterInfo> rows = jdbcTemplate.query(sql, preparedStatement -> preparedStatement.setString(1, value),
+                (resultSet, rowNum) -> mapRow(resultSet));
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    private EmployeeRosterInfo mapRow(ResultSet resultSet) throws SQLException {
+        EmployeeRosterInfo info = new EmployeeRosterInfo();
+        info.setId(Integer.valueOf(resultSet.getInt("id")));
+        info.setDingtalkId(resultSet.getString("dingtalk_id"));
+        info.setEmployeeNo(resultSet.getString("employee_no"));
+        info.setUsername(resultSet.getString("username"));
+        info.setJobTitle(resultSet.getString("job_title"));
+        info.setJobTitleId(resultSet.getString("job_title_id"));
+        info.setJobGrade(resultSet.getString("job_grade"));
+        info.setJobGradeId(resultSet.getString("job_grade_id"));
+        info.setDepartment(resultSet.getString("department"));
+        info.setFirstDepartment(resultSet.getString("first_department"));
+        return info;
+    }
+
+    private void bind(PreparedStatement statement, EmployeeRosterRecord record) throws java.sql.SQLException {
+        int index = 1;
+        statement.setString(index++, record.getDingtalkId());
+        statement.setString(index++, record.getEmployeeNo());
+        statement.setString(index++, record.getUsername());
+        statement.setString(index++, record.getJobTitle());
+        statement.setString(index++, record.getJobTitleId());
+        statement.setString(index++, record.getJobGrade());
+        statement.setString(index++, record.getJobGradeId());
+        statement.setString(index++, record.getDepartment());
+        statement.setString(index, record.getFirstDepartment());
+    }
+
+    private JdbcTemplate requireJdbcTemplate() {
+        JdbcTemplate jdbcTemplate = jdbcTemplateProvider.getIfAvailable();
+        if (jdbcTemplate == null) {
+            throw new IllegalStateException("MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_URL is not configured");
+        }
+        return jdbcTemplate;
+    }
+
+    private String createMergeSql(String tableName) {
+        return "INSERT INTO " + tableName + " " +
+                "(dingtalk_id, employee_no, username, job_title, job_title_id, job_grade, job_grade_id, department, first_department) " +
+                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " +
+                "ON DUPLICATE KEY UPDATE " +
+                "dingtalk_id = VALUES(dingtalk_id), " +
+                "employee_no = VALUES(employee_no), " +
+                "username = VALUES(username), " +
+                "job_title = VALUES(job_title), " +
+                "job_title_id = VALUES(job_title_id), " +
+                "job_grade = VALUES(job_grade), " +
+                "job_grade_id = VALUES(job_grade_id), " +
+                "department = VALUES(department), " +
+                "first_department = VALUES(first_department)";
+    }
+
+    private String resolveTableName() {
+        String tableName = properties.getTableName();
+        if (tableName == null || !tableName.matches("[A-Za-z][A-Za-z0-9_]*")) {
+            throw new IllegalStateException("minglei.employee-roster.table-name must be a simple SQL identifier");
+        }
+        return tableName;
+    }
+}

+ 31 - 0
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterScheduler.java

@@ -0,0 +1,31 @@
+package com.malk.minglei.service.impl.employee;
+
+import com.malk.minglei.dto.EmployeeRosterSyncResult;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+public class EmployeeRosterScheduler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(EmployeeRosterScheduler.class);
+
+    private final EmployeeRosterService employeeRosterService;
+
+    public EmployeeRosterScheduler(EmployeeRosterService employeeRosterService) {
+        this.employeeRosterService = employeeRosterService;
+    }
+
+    @Scheduled(cron = "0 0 0 * * ?")
+    public void syncFullEmployeeRoster() {
+        try {
+            EmployeeRosterSyncResult result = employeeRosterService.syncFull();
+            LOGGER.info("Employee roster full sync finished, userCount={}, fieldCount={}, syncedCount={}",
+                    result.getUserCount(), result.getFieldCount(), result.getSyncedCount());
+        } catch (RuntimeException ex) {
+            LOGGER.error("Employee roster full sync failed", ex);
+        }
+    }
+}

+ 532 - 0
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImpl.java

@@ -0,0 +1,532 @@
+package com.malk.minglei.service.impl.employee;
+
+import com.malk.minglei.config.EmployeeRosterProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.dto.EmployeeRosterSyncResult;
+import com.malk.minglei.service.employee.DingTalkEmployeeRosterClient;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+public class EmployeeRosterServiceImpl implements EmployeeRosterService {
+
+    private static final int INCLUDE_CHILD_DEPARTMENTS = 0;
+    private static final int CURRENT_DEPARTMENT_ONLY = 1;
+    private static final long ROOT_DEPARTMENT_ID = 1L;
+    private static final int DEFAULT_USER_PAGE_SIZE = 100;
+    private static final int DEFAULT_ROSTER_BATCH_SIZE = 20;
+
+    private final DDClient dingTalkClient;
+    private final DDClient_Contacts contactsClient;
+    private final DDConf dingTalkConfiguration;
+    private final DingTalkEmployeeRosterClient rosterClient;
+    private final EmployeeRosterRepository repository;
+    private final EmployeeRosterProperties properties;
+
+    public EmployeeRosterServiceImpl(DDClient dingTalkClient,
+                                     DDClient_Contacts contactsClient,
+                                     DDConf dingTalkConfiguration,
+                                     DingTalkEmployeeRosterClient rosterClient,
+                                     EmployeeRosterRepository repository,
+                                     EmployeeRosterProperties properties) {
+        this.dingTalkClient = dingTalkClient;
+        this.contactsClient = contactsClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.rosterClient = rosterClient;
+        this.repository = repository;
+        this.properties = properties;
+    }
+
+    @Override
+    public EmployeeRosterSyncResult sync(long departmentId, int type) {
+        if (departmentId < 1) {
+            throw new IllegalArgumentException("departmentId must be a positive integer");
+        }
+        if (type != INCLUDE_CHILD_DEPARTMENTS && type != CURRENT_DEPARTMENT_ONLY) {
+            throw new IllegalArgumentException("type must be 0 or 1");
+        }
+
+        String accessToken = dingTalkClient.getAccessToken();
+        require(accessToken, "Unable to obtain DingTalk access token");
+        Number agentId = dingTalkConfiguration.getAgentId();
+        if (agentId == null || agentId.longValue() < 1) {
+            throw new IllegalStateException("DINGTALK_AGENT_ID is not configured");
+        }
+
+        List<Map> fieldGroups = rosterClient.listRosterFieldGroups(accessToken, agentId);
+        List<String> fieldCodes = extractFieldCodes(fieldGroups);
+        Map<String, String> fieldNamesByCode = extractFieldNamesByCode(fieldGroups);
+
+        List<Map> users = listUsers(accessToken, departmentId, type);
+        Map<String, Map> rosterByUserId = fetchRosterByUserId(accessToken, agentId, users, fieldCodes);
+
+        List<EmployeeRosterRecord> records = new ArrayList<EmployeeRosterRecord>();
+        for (Map user : users) {
+            String userId = extractUserId(user);
+            if (userId == null) {
+                continue;
+            }
+            records.add(createRecord(userId, user, rosterByUserId.get(userId), fieldNamesByCode));
+        }
+
+        int syncedCount = repository.upsertAll(records);
+        return new EmployeeRosterSyncResult(departmentId, type == INCLUDE_CHILD_DEPARTMENTS, records.size(), fieldCodes.size(), syncedCount);
+    }
+
+    public EmployeeRosterSyncResult syncDefaultDepartment() {
+        long departmentId = properties.getDepartmentId();
+        int type = properties.isIncludeSubDepartments() ? INCLUDE_CHILD_DEPARTMENTS : CURRENT_DEPARTMENT_ONLY;
+        return sync(departmentId, type);
+    }
+
+    @Override
+    public EmployeeRosterSyncResult syncFull() {
+        return sync(ROOT_DEPARTMENT_ID, INCLUDE_CHILD_DEPARTMENTS);
+    }
+
+    @Override
+    public EmployeeRosterInfo findByUserId(String userId) {
+        if (userId == null || userId.trim().isEmpty()) {
+            throw new IllegalArgumentException("userId must not be blank");
+        }
+        return repository.findByDingtalkId(userId.trim());
+    }
+
+    @Override
+    public EmployeeRosterInfo findByEmployeeNo(String employeeNo) {
+        if (employeeNo == null || employeeNo.trim().isEmpty()) {
+            throw new IllegalArgumentException("employeeNo must not be blank");
+        }
+        return repository.findByEmployeeNo(employeeNo.trim());
+    }
+
+    private List<Map> listUsers(String accessToken, long departmentId, int type) {
+        Set<Long> departmentIds = new LinkedHashSet<Long>();
+        departmentIds.add(departmentId);
+        if (type == INCLUDE_CHILD_DEPARTMENTS) {
+            List<Long> childDepartmentIds = contactsClient.getDepartmentId_all(accessToken, false, departmentId);
+            if (childDepartmentIds != null) {
+                departmentIds.addAll(childDepartmentIds);
+            }
+        }
+
+        Map<String, Map> usersByUserId = new LinkedHashMap<String, Map>();
+        for (Long currentDepartmentId : departmentIds) {
+            if (currentDepartmentId != null) {
+                appendDepartmentUsers(accessToken, currentDepartmentId.longValue(), usersByUserId);
+            }
+        }
+        return new ArrayList<Map>(usersByUserId.values());
+    }
+
+    private void appendDepartmentUsers(String accessToken, long departmentId, Map<String, Map> usersByUserId) {
+        long cursor = 0L;
+        int pageSize = normalizePageSize(properties.getUserPageSize());
+        while (true) {
+            Map<String, Object> options = new LinkedHashMap<String, Object>();
+            options.put("cursor", cursor);
+            options.put("size", pageSize);
+            options.put("language", "zh_CN");
+
+            Map result = contactsClient.listDeptUserDetail_v2(accessToken, departmentId, options);
+            for (Map user : extractUserList(result)) {
+                String userId = extractUserId(user);
+                if (userId != null && !usersByUserId.containsKey(userId)) {
+                    usersByUserId.put(userId, user);
+                }
+            }
+
+            if (!toBoolean(result == null ? null : result.get("has_more"))) {
+                break;
+            }
+            Long nextCursor = toLong(result.get("next_cursor"));
+            if (nextCursor == null || nextCursor.longValue() == cursor) {
+                break;
+            }
+            cursor = nextCursor.longValue();
+        }
+    }
+
+    private Map<String, Map> fetchRosterByUserId(String accessToken, Number agentId, List<Map> users, List<String> fieldCodes) {
+        List<String> userIds = new ArrayList<String>();
+        for (Map user : users) {
+            String userId = extractUserId(user);
+            if (userId != null) {
+                userIds.add(userId);
+            }
+        }
+
+        Map<String, Map> rosterByUserId = new LinkedHashMap<String, Map>();
+        int batchSize = normalizeRosterBatchSize(properties.getRosterBatchSize());
+        for (int start = 0; start < userIds.size(); start += batchSize) {
+            int end = Math.min(start + batchSize, userIds.size());
+            List<String> batch = userIds.subList(start, end);
+            List<Map> rosterItems = rosterClient.listEmployeeRoster(accessToken, batch, agentId, fieldCodes);
+            for (Map rosterItem : rosterItems) {
+                String userId = extractUserId(rosterItem);
+                if (userId != null) {
+                    rosterByUserId.put(userId, rosterItem);
+                }
+            }
+        }
+        return rosterByUserId;
+    }
+
+    private EmployeeRosterRecord createRecord(String userId, Map user, Map roster, Map<String, String> fieldNamesByCode) {
+        Map<String, RosterFieldValue> rosterValues = extractRosterValues(roster, fieldNamesByCode);
+
+        EmployeeRosterRecord record = new EmployeeRosterRecord();
+        record.setDingtalkId(userId);
+        record.setEmployeeNo(firstNotBlank(
+                firstText(user, "job_number", "jobNumber", "employee_no", "employeeNo"),
+                findRosterValue(rosterValues, "工号", "员工工号", "工号ID", "employee_no", "job_number", "jobNumber")));
+        record.setUsername(firstNotBlank(
+                firstText(user, "name", "username", "user_name"),
+                findRosterValue(rosterValues, "姓名", "用户名", "员工姓名", "name", "username"),
+                userId));
+        record.setJobTitle(findRosterLabel(rosterValues, "职称", "001bc7f2-750d-4820-b9f6-6680acfffcb6", "job_title"));
+        record.setJobTitleId(findRosterValue(rosterValues, "职称", "001bc7f2-750d-4820-b9f6-6680acfffcb6", "job_title"));
+        record.setJobGrade(findRosterLabel(rosterValues, "职等", "ad410990-cee4-4e19-b70f-189a8ab87527", "job_grade"));
+        record.setJobGradeId(findRosterValue(rosterValues, "职等", "ad410990-cee4-4e19-b70f-189a8ab87527", "job_grade"));
+        record.setDepartment(findRosterValue(rosterValues, "部门", "所在部门", "部门名称", "department"));
+        record.setFirstDepartment(findRosterValue(rosterValues, "一级部门", "一级部门名称", "first_department"));
+        return record;
+    }
+
+    private List<Map> extractUserList(Map result) {
+        if (result == null) {
+            return Collections.emptyList();
+        }
+        Object list = firstNotNull(result.get("list"), result.get("userlist"), result.get("users"));
+        if (!(list instanceof Iterable)) {
+            return Collections.emptyList();
+        }
+        List<Map> users = new ArrayList<Map>();
+        for (Object item : (Iterable<?>) list) {
+            if (item instanceof Map) {
+                users.add((Map) item);
+            }
+        }
+        return users;
+    }
+
+    private List<String> extractFieldCodes(List<Map> fieldGroups) {
+        Set<String> fieldCodes = new LinkedHashSet<String>();
+        collectFieldCodes(fieldGroups, fieldCodes);
+        return new ArrayList<String>(fieldCodes);
+    }
+
+    private Map<String, String> extractFieldNamesByCode(List<Map> fieldGroups) {
+        Map<String, String> fieldNamesByCode = new LinkedHashMap<String, String>();
+        collectFieldNames(fieldGroups, fieldNamesByCode);
+        return fieldNamesByCode;
+    }
+
+    private void collectFieldNames(Object value, Map<String, String> fieldNamesByCode) {
+        if (value instanceof Map) {
+            Map map = (Map) value;
+            String fieldCode = firstText(map, "field_code", "fieldCode", "code");
+            String fieldName = firstText(map, "field_name", "fieldName", "name", "label");
+            if (fieldCode != null && fieldName != null) {
+                fieldNamesByCode.put(fieldCode, fieldName);
+            }
+            for (Object child : map.values()) {
+                collectFieldNames(child, fieldNamesByCode);
+            }
+            return;
+        }
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                collectFieldNames(item, fieldNamesByCode);
+            }
+        }
+    }
+
+    private void collectFieldCodes(Object value, Set<String> fieldCodes) {
+        if (value instanceof Map) {
+            Map map = (Map) value;
+            addFieldCode(fieldCodes, map.get("field_code"));
+            addFieldCode(fieldCodes, map.get("fieldCode"));
+            for (Object child : map.values()) {
+                collectFieldCodes(child, fieldCodes);
+            }
+            return;
+        }
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                collectFieldCodes(item, fieldCodes);
+            }
+        }
+    }
+
+    private void addFieldCode(Set<String> fieldCodes, Object value) {
+        String fieldCode = toStringValue(value);
+        if (fieldCode != null) {
+            fieldCodes.add(fieldCode);
+        }
+    }
+
+    private String extractUserId(Map user) {
+        if (user == null) {
+            return null;
+        }
+        return firstText(user, "userid", "userId", "user_id");
+    }
+
+    private Map<String, RosterFieldValue> extractRosterValues(Map roster, Map<String, String> fieldNamesByCode) {
+        Map<String, RosterFieldValue> values = new LinkedHashMap<String, RosterFieldValue>();
+        collectRosterValues(roster, fieldNamesByCode, values);
+        return values;
+    }
+
+    private void collectRosterValues(Object value, Map<String, String> fieldNamesByCode, Map<String, RosterFieldValue> values) {
+        if (value instanceof Map) {
+            Map map = (Map) value;
+            String fieldCode = firstText(map, "field_code", "fieldCode", "code");
+            if (fieldCode != null) {
+                String fieldName = firstNotBlank(firstText(map, "field_name", "fieldName", "name", "label"), fieldNamesByCode.get(fieldCode), fieldCode);
+                RosterFieldValue fieldValue = extractRosterFieldValue(map);
+                if (!fieldValue.isEmpty()) {
+                    values.put(normalizeKey(fieldName), fieldValue);
+                    values.put(normalizeKey(fieldCode), fieldValue);
+                }
+            }
+            for (Object child : map.values()) {
+                collectRosterValues(child, fieldNamesByCode, values);
+            }
+            return;
+        }
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                collectRosterValues(item, fieldNamesByCode, values);
+            }
+        }
+    }
+
+    private RosterFieldValue extractRosterFieldValue(Map map) {
+        RosterFieldValue value = new RosterFieldValue();
+        value.setLabel(firstText(map, "label", "field_label", "fieldLabel", "label_value", "labelValue"));
+        value.setValue(firstText(map, "field_value", "fieldValue", "value", "text"));
+
+        Object fieldValueList = firstNotNull(map.get("field_value_list"), map.get("fieldValueList"), map.get("values"));
+        if (fieldValueList instanceof Iterable) {
+            List<String> labels = new ArrayList<String>();
+            List<String> values = new ArrayList<String>();
+            for (Object item : (Iterable<?>) fieldValueList) {
+                if (item instanceof Map) {
+                    Map itemMap = (Map) item;
+                    addText(labels, firstText(itemMap, "label", "text", "name"));
+                    addText(values, firstText(itemMap, "value", "id", "code"));
+                } else {
+                    String text = toStringValue(item);
+                    addText(labels, text);
+                    addText(values, text);
+                }
+            }
+            if (!labels.isEmpty()) {
+                value.setLabel(String.join(",", labels));
+            }
+            if (!values.isEmpty()) {
+                value.setValue(String.join(",", values));
+            }
+        }
+
+        if (value.getLabel() == null) {
+            value.setLabel(value.getValue());
+        }
+        if (value.getValue() == null) {
+            value.setValue(value.getLabel());
+        }
+        return value;
+    }
+
+    private void addText(List<String> texts, String value) {
+        String text = toStringValue(value);
+        if (text != null) {
+            texts.add(text);
+        }
+    }
+
+    private String findRosterLabel(Map<String, RosterFieldValue> rosterValues, String firstKey, String... otherKeys) {
+        RosterFieldValue value = rosterValues.get(normalizeKey(firstKey));
+        if (value != null) {
+            return value.getLabel();
+        }
+        for (String key : otherKeys) {
+            value = rosterValues.get(normalizeKey(key));
+            if (value != null) {
+                return value.getLabel();
+            }
+        }
+        return null;
+    }
+
+    private String findRosterValue(Map<String, RosterFieldValue> rosterValues, String firstKey, String... otherKeys) {
+        RosterFieldValue value = rosterValues.get(normalizeKey(firstKey));
+        if (value != null) {
+            return value.getValue();
+        }
+        for (String key : otherKeys) {
+            value = rosterValues.get(normalizeKey(key));
+            if (value != null) {
+                return value.getValue();
+            }
+        }
+        return null;
+    }
+
+    private String firstText(Map map, String firstKey, String... otherKeys) {
+        String value = toStringValue(map.get(firstKey));
+        if (value != null) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = toStringValue(map.get(key));
+            if (value != null) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Object firstNotNull(Object first, Object second) {
+        return first == null ? second : first;
+    }
+
+    private Object firstNotNull(Object first, Object second, Object third) {
+        if (first != null) {
+            return first;
+        }
+        return second == null ? third : second;
+    }
+
+    private String firstNotBlank(String first, String... others) {
+        String value = toStringValue(first);
+        if (value != null) {
+            return value;
+        }
+        for (String other : others) {
+            value = toStringValue(other);
+            if (value != null) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Boolean toBoolean(Object value) {
+        if (value instanceof Boolean) {
+            return (Boolean) value;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).intValue() != 0;
+        }
+        if (value != null) {
+            return Boolean.valueOf(String.valueOf(value));
+        }
+        return Boolean.FALSE;
+    }
+
+    private Long toLong(Object value) {
+        if (value instanceof Number) {
+            return Long.valueOf(((Number) value).longValue());
+        }
+        String text = toStringValue(value);
+        if (text == null) {
+            return null;
+        }
+        return Long.valueOf(text);
+    }
+
+    private int normalizePageSize(int pageSize) {
+        if (pageSize < 1) {
+            return DEFAULT_USER_PAGE_SIZE;
+        }
+        return Math.min(pageSize, DEFAULT_USER_PAGE_SIZE);
+    }
+
+    private int normalizeRosterBatchSize(int batchSize) {
+        if (batchSize < 1) {
+            return DEFAULT_ROSTER_BATCH_SIZE;
+        }
+        return Math.min(batchSize, DEFAULT_ROSTER_BATCH_SIZE);
+    }
+
+    private String toStringValue(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Iterable) {
+            return toDisplayText(value);
+        }
+        String text = String.valueOf(value).trim();
+        return text.isEmpty() ? null : text;
+    }
+
+    private String toDisplayText(Object value) {
+        if (value instanceof Iterable) {
+            List<String> texts = new ArrayList<String>();
+            for (Object item : (Iterable<?>) value) {
+                String text = toDisplayText(item);
+                if (text != null) {
+                    texts.add(text);
+                }
+            }
+            return texts.isEmpty() ? null : String.join(",", texts);
+        }
+        if (value instanceof Map) {
+            Map map = (Map) value;
+            String text = firstText(map, "value", "text", "label", "name");
+            return text == null ? null : text;
+        }
+        return toStringValue(value);
+    }
+
+    private String normalizeKey(String value) {
+        String text = toStringValue(value);
+        return text == null ? "" : text.replace(" ", "").replace("_", "").replace("-", "").toLowerCase();
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+
+    private static class RosterFieldValue {
+
+        private String label;
+        private String value;
+
+        String getLabel() {
+            return label;
+        }
+
+        void setLabel(String label) {
+            this.label = label;
+        }
+
+        String getValue() {
+            return value;
+        }
+
+        void setValue(String value) {
+            this.value = value;
+        }
+
+        boolean isEmpty() {
+            return label == null && value == null;
+        }
+    }
+}

+ 132 - 0
src/main/java/com/malk/minglei/service/impl/port/MingleiPortServiceImpl.java

@@ -0,0 +1,132 @@
+package com.malk.minglei.service.impl.port;
+
+import com.malk.minglei.config.MingleiPortProperties;
+import com.malk.minglei.dto.MingleiPortRequest;
+import com.malk.minglei.service.port.MingleiPortService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 使用 HTTP 客户端调用供应商价格审批单(核价)外部接口。
+ */
+@Service
+public class MingleiPortServiceImpl implements MingleiPortService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(MingleiPortServiceImpl.class);
+    private static final String TYPE = "sync";
+    private static final String HOST_PRODUCT = "APP";
+    private static final String HOST_IP = "10.0.2.60";
+    private static final String HOST_LANGUAGE = "zh_CN";
+    private static final String HOST_ACCOUNT = "tiptop";
+    private static final String HOST_TIMESTAMP = "20260807164700004";
+    private static final String SERVICE_PRODUCT = "T100";
+    private static final String SERVICE_NAME = "ProcurementApprovalPrice";
+    private static final String SERVICE_ID = "toptst";
+    private static final String ENTERPRISE_ID = "87";
+    private static final String COMPANY_ID = "ML";
+
+    private final RestTemplate restTemplate;
+    private final MingleiPortProperties properties;
+
+    public MingleiPortServiceImpl(@Qualifier("mingleiPortRestTemplate") RestTemplate restTemplate,
+                                  MingleiPortProperties properties) {
+        this.restTemplate = restTemplate;
+        this.properties = properties;
+    }
+
+    /**
+     * 将供应商价格审批单 JSON 按原有字段结构发送到外部 T100 接口。
+     *
+     * @param request 仅包含 head 和 detail 的本地请求体
+     * @return 外部接口原始响应体;外部响应无请求体时返回空 Map
+     */
+    @Override
+    public Map<String, Object> submitSupplierPriceApproval(MingleiPortRequest request) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
+
+        validateConfiguration();
+        LOGGER.info("Submitting supplier price approval documentNo={} detailCount={}",
+                new Object[]{request.getDocumentNumber(), request.getDetailCount()});
+
+        ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
+                properties.getSupplierPriceApprovalUrl(),
+                HttpMethod.POST,
+                new HttpEntity<Map<String, Object>>(createExternalRequest(request), headers),
+                new ParameterizedTypeReference<Map<String, Object>>() {
+                });
+        return response.getBody() == null ? Collections.<String, Object>emptyMap() : response.getBody();
+    }
+
+    /**
+     * 使用固定的外层协议字段组装外部接口请求,仅透传调用方提供的 head 和 detail。
+     *
+     * @param request 本地精简请求体
+     * @return 外部接口完整请求体
+     */
+    private Map<String, Object> createExternalRequest(MingleiPortRequest request) {
+        Map<String, Object> host = new LinkedHashMap<String, Object>();
+        host.put("prod", HOST_PRODUCT);
+        host.put("ip", HOST_IP);
+        host.put("lang", HOST_LANGUAGE);
+        host.put("acct", HOST_ACCOUNT);
+        host.put("timestamp", HOST_TIMESTAMP);
+
+        Map<String, Object> service = new LinkedHashMap<String, Object>();
+        service.put("prod", SERVICE_PRODUCT);
+        service.put("name", SERVICE_NAME);
+        service.put("ip", HOST_IP);
+        service.put("id", SERVICE_ID);
+
+        Map<String, Object> dataKey = new LinkedHashMap<String, Object>();
+        dataKey.put("EntId", ENTERPRISE_ID);
+        dataKey.put("CompanyId", COMPANY_ID);
+
+        Map<String, Object> parameter = new LinkedHashMap<String, Object>();
+        parameter.put("head", request.getHead());
+        parameter.put("detail", request.getDetail());
+
+        Map<String, Object> standardData = new LinkedHashMap<String, Object>();
+        standardData.put("parameter", parameter);
+
+        Map<String, Object> payload = new LinkedHashMap<String, Object>();
+        payload.put("std_data", standardData);
+
+        Map<String, Object> externalRequest = new LinkedHashMap<String, Object>();
+        externalRequest.put("key", properties.getSupplierPriceApprovalKey());
+        externalRequest.put("type", TYPE);
+        externalRequest.put("host", host);
+        externalRequest.put("service", service);
+        externalRequest.put("datakey", dataKey);
+        externalRequest.put("payload", payload);
+        return externalRequest;
+    }
+
+    /**
+     * 校验调用外部接口需要的地址和密钥配置是否完整。
+     */
+    private void validateConfiguration() {
+        require(properties.getSupplierPriceApprovalUrl(), "MINGLEI_SUPPLIER_PRICE_APPROVAL_URL is not configured");
+        require(properties.getSupplierPriceApprovalKey(), "MINGLEI_SUPPLIER_PRICE_APPROVAL_KEY is not configured");
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

+ 469 - 0
src/main/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandler.java

@@ -0,0 +1,469 @@
+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;
+
+@Service
+public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ApproveOrderFormPushHandler.class);
+    private static final String START_PROCESS_ENDPOINT = "/v1.0/yida/processes/instances/start";
+    public static final String TYPE = "axmt500";
+    private static final String FORM_NAME = "approveOrder";
+    private static final String FORM_UUID = "FORM-8454FA17732746058A2D90B1C559CC19G5SR";
+    private static final String PROCESS_CODE = "TPROC--9VD66QA18AJ8317KK3NXM54W1HIT30C7N28TM7";
+    private static final String DEFAULT_ORIGINATOR_USER_ID = "41603502201288023";
+
+    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_mtv87n9f";
+    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 URGENCY_FIELD_ID = "textField_mt7zi4fy";
+    private static final String ENTERPRISE_NO_FIELD_ID = "textField_mt82p09i";
+    private static final String ORDER_NO_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 CUSTOMER_CODE_FIELD_ID = "textField_mt80nq6v";
+    private static final String CUSTOMER_SHORT_NAME_FIELD_ID = "textField_mt82p08x";
+    private static final String ORDER_TYPE_FIELD_ID = "textField_mt82p091";
+    private static final String PRICE_TAX_INCLUDED_FIELD_ID = "textField_mt82p092";
+    private static final String ORDER_AMOUNT_FIELD_ID = "textField_mt82p094";
+    private static final String REMARK_FIELD_ID = "textField_mt82p097";
+    private static final String SPECIAL_DELIVERY_FIELD_ID = "selectField_mt82p09c";
+    private static final String APPROVAL_RESULT_FIELD_ID = "selectField_mtgsgnvi";
+    private static final String RESPONSIBILITY_FIELD_ID = "checkboxField_mtgsgnvj";
+
+    private static final String DELIVERY_TABLE_FIELD_ID = "tableField_mt83ctpv";
+    private static final String DELIVERY_SALES_QUANTITY_FIELD_ID = "textField_mt82p0al";
+    private static final String DELIVERY_PROMISED_DATE_FIELD_ID = "dateField_mt82p09r";
+    private static final String DELIVERY_PRICE_FIELD_ID = "textField_mt82p09t";
+    private static final String DELIVERY_CHILD_FEATURE_FIELD_ID = "textField_mt82p0an";
+    private static final String DELIVERY_PRE_TAX_AMOUNT_FIELD_ID = "textField_mt82p0ao";
+    private static final String DELIVERY_TAX_INCLUDED_AMOUNT_FIELD_ID = "textField_mt82p0ap";
+    private static final String DELIVERY_APPLY_DATE_FIELD_ID = "dateField_mt82p09w";
+
+    private static final String DETAIL_TABLE_FIELD_ID = "tableField_8yaa9o8qk";
+    private static final String DETAIL_ENTERPRISE_NO_FIELD_ID = "textField_8yaaaieif";
+    private static final String DETAIL_ORDER_NO_FIELD_ID = "textField_8yabb4o9b";
+    private static final String DETAIL_ORDER_ITEM_FIELD_ID = "numberField_mt82p0af";
+    private static final String DETAIL_ITEM_SEQ_FIELD_ID = "numberField_mt82p0ah";
+    private static final String DETAIL_BATCH_SEQ_FIELD_ID = "numberField_mt82p0ai";
+    private static final String DETAIL_MATERIAL_CODE_FIELD_ID = "textField_8yabff0mk";
+    private static final String DETAIL_ITEM_NAME_FIELD_ID = "textField_8yabgv4yp";
+    private static final String DETAIL_SPECIFICATION_FIELD_ID = "textField_8yabhgci5";
+    private static final String DETAIL_PRODUCT_FEATURE_FIELD_ID = "textField_8yabis8xh";
+    private static final String DETAIL_SALES_UNIT_FIELD_ID = "textField_8yabjecf7";
+    private static final String DETAIL_DELIVERY_TYPE_FIELD_ID = "textField_mt82p0aj";
+    private static final String DETAIL_TOTAL_QUANTITY_FIELD_ID = "textField_8yabl1tlp";
+    private static final String DETAIL_PROMISED_DATE_FIELD_ID = "dateField_8yabm8rmr";
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties applications;
+
+    public ApproveOrderFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                       DDConf dingTalkConfiguration,
+                                       YidaApplicationsProperties applications) {
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.applications = applications;
+    }
+
+    @Override
+    public List<String> getTypes() {
+        return Collections.singletonList(TYPE);
+    }
+
+    @Override
+    public String push(ErpYidaPushRequest request) {
+        validateConfiguration();
+
+        String operatorUserId = resolveOperatorUserId(request);
+        String appType = requireText(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        String systemToken = requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+        Map<String, Object> formData = toYidaFormData(request, operatorUserId);
+        String title = requireText(toText(formData.get(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);
+        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)));
+
+        DDR_New response;
+        try {
+            response = 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));
+        }
+        return extractProcessInstanceId(response);
+    }
+
+    private Map<String, Object> toYidaFormData(ErpYidaPushRequest request, String operatorUserId) {
+        ErpYidaPushMainData mainData = request.getMainData();
+        Map<String, Object> main = objectToMap(mainData);
+        putIfNotBlank(main, "bm", request.getBm());
+        putIfNotBlank(main, "gs", request.getGs());
+        putIfNotBlank(main, "tjr", request.getTjr());
+        Map<String, Object> detailWrapper = objectToMap(request.getDetailData());
+        Object legacyRows = detailWrapper.get("detailData");
+        Object numberedRows1 = detailWrapper.get("detailData1");
+        Object numberedRows2 = detailWrapper.get("detailData2");
+        Object deliverySource = firstNonBlank(numberedRows1, legacyRows);
+        Object orderSource = firstNonBlank(numberedRows2, legacyRows);
+        if (isBlank(numberedRows2) && !isBlank(legacyRows) && !isBlank(numberedRows1)) {
+            deliverySource = legacyRows;
+            orderSource = numberedRows1;
+        }
+        List<Map<String, Object>> deliverySourceRows = toMapRows(deliverySource);
+        List<Map<String, Object>> orderSourceRows = toMapRows(orderSource);
+        String orderNo = toText(value(main, "xmdadocno"));
+        String title = toText(firstNonBlank(value(main, "title", "dataTitle"), orderNo == null ? null : "approveOrder-" + orderNo));
+
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        putIfNotBlank(formData, TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, APPLICANT_FIELD_ID, toStringList(operatorUserId, null));
+        putIfNotBlank(formData, COMPANY_FIELD_ID, firstNonBlank(value(main, "gs", "applyCompany", "company"), value(main, "xmdaent")));
+        putIfNotBlank(formData, DEPARTMENT_FIELD_ID, toStringList(value(main, "bm", "applyDepartment", "department", "deptId"), null));
+        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(firstNonBlank(value(main, "rq", "applyDate"), value(main, "xmdadocdt"))));
+        putIfNotBlank(formData, FORM_TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, URGENCY_FIELD_ID, firstNonBlank(value(main, "urgency"), "正常"));
+        putIfNotBlank(formData, ENTERPRISE_NO_FIELD_ID, value(main, "xmdaent"));
+        putIfNotBlank(formData, ORDER_NO_FIELD_ID, value(main, "xmdadocno"));
+        putIfNotBlank(formData, ORDER_DATE_FIELD_ID, toDateMillis(value(main, "xmdadocdt")));
+        putIfNotBlank(formData, VERSION_FIELD_ID, toDecimal(value(main, "xmda001")));
+        putIfNotBlank(formData, SALES_PERSON_FIELD_ID, toStringList(value(main, "xmda002"), null));
+        putIfNotBlank(formData, SALES_DEPARTMENT_FIELD_ID, toStringList(value(main, "xmda003"), null));
+        putIfNotBlank(formData, CUSTOMER_CODE_FIELD_ID, value(main, "xmda004"));
+        putIfNotBlank(formData, CUSTOMER_SHORT_NAME_FIELD_ID, value(main, "pmaal004"));
+        putIfNotBlank(formData, ORDER_TYPE_FIELD_ID, value(main, "xmda005"));
+        putIfNotBlank(formData, PRICE_TAX_INCLUDED_FIELD_ID, value(main, "xmda013"));
+        putIfNotBlank(formData, ORDER_AMOUNT_FIELD_ID, value(main, "xmda042"));
+        putIfNotBlank(formData, REMARK_FIELD_ID, value(main, "xmda071"));
+        putIfNotBlank(formData, SPECIAL_DELIVERY_FIELD_ID, firstNonBlank(value(main, "specialDelivery"), "否"));
+        putIfNotBlank(formData, APPROVAL_RESULT_FIELD_ID, firstNonBlank(value(main, "approvalResult"), "审核"));
+        putIfNotBlank(formData, RESPONSIBILITY_FIELD_ID, toStringList(firstNonBlank(value(main, "responsibility"), "无"), null));
+
+        List<Map<String, Object>> deliveryRows = toDeliveryRows(deliverySourceRows);
+        if (!deliveryRows.isEmpty()) {
+            formData.put(DELIVERY_TABLE_FIELD_ID, deliveryRows);
+        }
+        List<Map<String, Object>> orderRows = toOrderRows(orderSourceRows);
+        if (!orderRows.isEmpty()) {
+            formData.put(DETAIL_TABLE_FIELD_ID, orderRows);
+        }
+        copyYidaFieldValues(main, formData);
+        return formData;
+    }
+
+    private List<Map<String, Object>> toDeliveryRows(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, DELIVERY_SALES_QUANTITY_FIELD_ID, value(source, "xmdc007"));
+            putIfNotBlank(row, DELIVERY_PROMISED_DATE_FIELD_ID, toDateMillis(value(source, "xmdc012")));
+            putIfNotBlank(row, DELIVERY_PRICE_FIELD_ID, value(source, "xmdc015"));
+            putIfNotBlank(row, DELIVERY_CHILD_FEATURE_FIELD_ID, value(source, "xmdc019"));
+            putIfNotBlank(row, DELIVERY_PRE_TAX_AMOUNT_FIELD_ID, value(source, "xmdc046"));
+            putIfNotBlank(row, DELIVERY_TAX_INCLUDED_AMOUNT_FIELD_ID, value(source, "xmdc047"));
+            putIfNotBlank(row, DELIVERY_APPLY_DATE_FIELD_ID, toDateMillis(value(source, "xmdcua003")));
+            copyYidaFieldValues(source, row);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<Map<String, Object>> toOrderRows(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, DETAIL_ENTERPRISE_NO_FIELD_ID, value(source, "xmddent", "xmdcent"));
+            putIfNotBlank(row, DETAIL_ORDER_NO_FIELD_ID, value(source, "xmdddocno", "xmdcdocno"));
+            putIfNotBlank(row, DETAIL_ORDER_ITEM_FIELD_ID, toDecimal(value(source, "xmddseq", "xmdcseq")));
+            putIfNotBlank(row, DETAIL_ITEM_SEQ_FIELD_ID, toDecimal(value(source, "xmddseq1", "xmdcseq")));
+            putIfNotBlank(row, DETAIL_BATCH_SEQ_FIELD_ID, toDecimal(value(source, "xmddseq2", "xmdcseq")));
+            putIfNotBlank(row, DETAIL_MATERIAL_CODE_FIELD_ID, value(source, "xmdd001", "xmdc001"));
+            putIfNotBlank(row, DETAIL_ITEM_NAME_FIELD_ID, value(source, "imaal003"));
+            putIfNotBlank(row, DETAIL_SPECIFICATION_FIELD_ID, value(source, "imaal004"));
+            putIfNotBlank(row, DETAIL_PRODUCT_FEATURE_FIELD_ID, value(source, "xmdd002", "xmdc002"));
+            putIfNotBlank(row, DETAIL_SALES_UNIT_FIELD_ID, value(source, "xmdd004", "xmdc006"));
+            putIfNotBlank(row, DETAIL_DELIVERY_TYPE_FIELD_ID, value(source, "xmdd009", "xmdc050"));
+            putIfNotBlank(row, DETAIL_TOTAL_QUANTITY_FIELD_ID, value(source, "xmdd005", "xmdc007"));
+            putIfNotBlank(row, DETAIL_PROMISED_DATE_FIELD_ID, toDateMillis(value(source, "xmdd011", "xmdc012")));
+            copyYidaFieldValues(source, row);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<Map<String, Object>> toMapRows(Object value) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Object item : toList(value)) {
+            Map<String, Object> row = objectToMap(item);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<?> toList(Object value) {
+        if (value instanceof List) {
+            return (List<?>) value;
+        }
+        if (value instanceof Iterable) {
+            List<Object> result = new ArrayList<Object>();
+            for (Object item : (Iterable<?>) value) {
+                result.add(item);
+            }
+            return result;
+        }
+        return value == null ? Collections.emptyList() : Collections.singletonList(value);
+    }
+
+    private List<String> toStringList(Object value, String fallback) {
+        Object actualValue = isBlank(value) ? fallback : value;
+        if (actualValue == null) {
+            return Collections.emptyList();
+        }
+        if (actualValue instanceof Iterable) {
+            List<String> result = new ArrayList<String>();
+            for (Object item : (Iterable<?>) actualValue) {
+                if (!isBlank(item)) {
+                    result.add(String.valueOf(item).trim());
+                }
+            }
+            return result;
+        }
+        return Collections.singletonList(String.valueOf(actualValue).trim());
+    }
+
+    private BigDecimal toDecimal(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        if (value instanceof Number) {
+            return new BigDecimal(String.valueOf(value));
+        }
+        return new BigDecimal(String.valueOf(value).trim());
+    }
+
+    private Long toDateMillis(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.matches("\\d+")) {
+            return Long.valueOf(text);
+        }
+        return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+    }
+
+    private Object value(Map<String, Object> source, String firstKey, String... otherKeys) {
+        Object value = source.get(firstKey);
+        if (!isBlank(value)) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = source.get(key);
+            if (!isBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Object firstNonBlank(Object first, Object second) {
+        return isBlank(first) ? second : first;
+    }
+
+    private void putIfNotBlank(Map<String, Object> target, String fieldId, Object value) {
+        if (!isBlank(value)) {
+            target.put(fieldId, value);
+        }
+    }
+
+    private void copyYidaFieldValues(Map<String, Object> source, Map<String, Object> target) {
+        for (Map.Entry<String, Object> entry : source.entrySet()) {
+            if (entry.getKey() != null && entry.getKey().contains("Field_") && !isBlank(entry.getValue())) {
+                target.put(entry.getKey(), entry.getValue());
+            }
+        }
+    }
+
+    private Map<String, Object> objectToMap(Object value) {
+        if (value == null) {
+            return Collections.emptyMap();
+        }
+        if (value instanceof Map) {
+            return copyMap((Map<?, ?>) value);
+        }
+        if (value instanceof ErpYidaPushMainData) {
+            ErpYidaPushMainData mainData = (ErpYidaPushMainData) value;
+            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;
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
+    }
+
+    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 resolveOperatorUserId(ErpYidaPushRequest request) {
+        if (!isBlank(request.getOriginatorUserId())) {
+            return request.getOriginatorUserId().trim();
+        }
+        Map<String, Object> main = objectToMap(request.getMainData());
+        Object operator = firstNonBlank(value(main, "originatorUserId", "userId"), value(main, "applicant"));
+        return String.valueOf(isBlank(operator) ? DEFAULT_ORIGINATOR_USER_ID : firstItem(operator)).trim();
+    }
+
+    private Object firstItem(Object value) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                if (!isBlank(item)) {
+                    return item;
+                }
+            }
+            return null;
+        }
+        return value;
+    }
+
+    private String toText(Object value) {
+        return isBlank(value) ? null : String.valueOf(value);
+    }
+
+    private Map<String, Object> maskSensitive(Map<String, Object> source) {
+        Map<String, Object> body = new LinkedHashMap<String, Object>(source);
+        if (body.containsKey("systemToken")) {
+            body.put("systemToken", "******");
+        }
+        return body;
+    }
+
+    private String extractProcessInstanceId(DDR_New response) {
+        if (response == null) {
+            throw new McException("YIDA_NULL_RESPONSE", "Yida process start failed: empty response");
+        }
+        LOGGER.info("Yida process start response formName={} success={} code={} result={} instanceId={} formInstId={}",
+                FORM_NAME, response.isSuccess(), response.getCode(), response.getResult(), response.getInstanceId(), response.getFormInstId());
+
+        Object result = firstNonBlank(firstNonBlank(response.getResult(), response.getInstanceId()), response.getFormInstId());
+        if (!isBlank(result)) {
+            return String.valueOf(result);
+        }
+        if (response.isSuccess()) {
+            return "SUCCESS";
+        }
+        String code = defaultText(response.getCode(), "YIDA_ERROR");
+        String message = defaultText(response.getMessage(), "Yida process start failed: no process instance id");
+        throw new McException(code, "Yida API [startProcess] failed: " + message);
+    }
+
+    private String defaultText(String value, String fallback) {
+        return value == null || value.trim().isEmpty() ? fallback : value.trim();
+    }
+
+    private String rootMessage(Throwable exception) {
+        Throwable current = exception;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        String message = current.getMessage();
+        if (message == null || message.trim().isEmpty()) {
+            message = exception.getMessage();
+        }
+        if (message == null || message.trim().isEmpty()) {
+            message = current.getClass().getSimpleName();
+        }
+        return message.trim();
+    }
+
+    private void validateConfiguration() {
+        require(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        require(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        require(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        require(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+
+    private String requireText(String value, String message) {
+        require(value, message);
+        return value.trim();
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

+ 89 - 0
src/main/java/com/malk/minglei/service/impl/yida/BomYidaProcessHandler.java

@@ -0,0 +1,89 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.YidaOperationParam;
+import com.malk.minglei.dto.YidaProcessStartRequest;
+import com.malk.minglei.service.yida.YidaProcessHandler;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.aliwork.YDClient;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+/**
+ * 处理 BOM 表单的宜搭流程发起请求。
+ */
+@Service
+public class BomYidaProcessHandler implements YidaProcessHandler {
+
+    private static final String FORM_UUID = "FORM-04001902BF9C40E6859102569FE139179LWH";
+    private static final String PROCESS_CODE = "TPROC--NNC669C1FTC8TKCLO79LYBRKZB2U3MT3QNTSM0";
+
+    /** 统一接口使用目标表单 ID 作为流程类型。 */
+    public static final String TYPE = FORM_UUID;
+
+    private final YDClient yidaClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
+
+    public BomYidaProcessHandler(@Qualifier("coordinationOfficeYidaClient") YDClient yidaClient,
+                                 DDConf dingTalkConfiguration,
+                                 YidaApplicationsProperties applications) {
+        this.yidaClient = yidaClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.coordinationOfficeConfiguration = applications.getCoordinationOffice();
+    }
+
+    /**
+     * 返回 BOM 表单 ID,作为统一接口的流程类型。
+     *
+     * @return BOM 表单 ID
+     */
+    @Override
+    public String getType() {
+        return TYPE;
+    }
+
+    /**
+     * 组装 BOM 表单参数,并通过 malk 宜搭客户端发起流程。
+     *
+     * @param request 统一流程发起请求
+     * @return 宜搭返回的流程实例 ID
+     */
+    @Override
+    public String startProcess(YidaProcessStartRequest request) {
+        validateConfiguration();
+
+        YidaOperationParam parameter = new YidaOperationParam();
+        parameter.setAppType(coordinationOfficeConfiguration.getAppType());
+        parameter.setSystemToken(coordinationOfficeConfiguration.getSystemToken());
+        parameter.setUserId(request.getUserId());
+        parameter.setFormUuid(FORM_UUID);
+        parameter.setProcessCode(PROCESS_CODE);
+        parameter.setFormDataJson(JSON.toJSONString(request.getFields()));
+        parameter.setDataTitle(request.getDataTitle());
+
+        Object result = yidaClient.operateData(parameter, YDConf.FORM_OPERATION.start);
+        if (result == null) {
+            throw new IllegalStateException("Yida process start returned an empty result");
+        }
+        return String.valueOf(result);
+    }
+
+    /**
+     * 校验 BOM 表单流程调用所需的应用级鉴权配置是否完整。
+     */
+    private void validateConfiguration() {
+        require(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        require(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        require(coordinationOfficeConfiguration.getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        require(coordinationOfficeConfiguration.getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

+ 253 - 0
src/main/java/com/malk/minglei/service/impl/yida/Cpmp402YidaProcessHandler.java

@@ -0,0 +1,253 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.YidaOperationParam;
+import com.malk.minglei.dto.YidaProcessStartRequest;
+import com.malk.minglei.service.yida.YidaProcessHandler;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.aliwork.YDClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+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;
+
+/**
+ * 处理包材核价单(cpmp402)对应的宜搭流程发起。
+ */
+@Service
+public class Cpmp402YidaProcessHandler implements YidaProcessHandler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(Cpmp402YidaProcessHandler.class);
+    private static final String START_PROCESS_ENDPOINT = "/v1.0/yida/processes/instances/start";
+    public static final String TYPE = "cpmp402";
+    private static final String FORM_UUID = "FORM-A3D4889187E54697A59590BE54F350E7AOO5";
+    private static final String PROCESS_CODE = "TPROC--98G66QB1NTF8E1R1LI0OK4D6JI9437HC92YSM1";
+    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 APPLY_DATE_FIELD_ID = "dateField_msh186rs";
+    private static final String FORM_TITLE_FIELD_ID = "textField_mspcpz92";
+    private static final String REGION_FIELD_ID = "selectField_msy2akpj";
+    private static final String T100_ID_FIELD_ID = "textField_msy2akpi";
+    private static final String T100_DEPARTMENT_FIELD_ID = "textField_msy2akpk";
+    private static final String DETAIL_TABLE_FIELD_ID = "tableField_msh186t3";
+    private static final String DETAIL_PRICE_NO_FIELD_ID = "textField_msy2akp1";
+    private static final String DETAIL_SUPPLIER_CODE_FIELD_ID = "textField_msy2akp2";
+    private static final String DETAIL_SUPPLIER_NAME_FIELD_ID = "textField_msy2akp3";
+    private static final String DETAIL_T100_ID_FIELD_ID = "textField_msy2akpp";
+    private static final String DETAIL_REGION_FIELD_ID = "selectField_msy2akpq";
+    private static final String DETAIL_LANGUAGE_FIELD_ID = "textField_msy2akpr";
+
+    private final YDClient yidaClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties applications;
+
+    public Cpmp402YidaProcessHandler(@Qualifier("coordinationOfficeYidaClient") YDClient yidaClient,
+                                     DDConf dingTalkConfiguration,
+                                     YidaApplicationsProperties applications) {
+        this.yidaClient = yidaClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.applications = applications;
+    }
+
+    @Override
+    public String getType() {
+        return TYPE;
+    }
+
+    @Override
+    public String startProcess(YidaProcessStartRequest request) {
+        validateConfiguration();
+
+        YidaOperationParam parameter = new YidaOperationParam();
+        parameter.setAppType(requireText(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured"));
+        parameter.setSystemToken(requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured"));
+        parameter.setUserId(requireText(request.getUserId(), "Yida process userId is not configured"));
+        parameter.setFormUuid(FORM_UUID);
+        parameter.setProcessCode(PROCESS_CODE);
+        String title = requireText(request.getDataTitle(), "Yida process dataTitle is not configured");
+        parameter.setFormDataJson(JSON.toJSONString(toYidaFormData(request, title)));
+        parameter.setExecuteExpression(true);
+        parameter.setNoExecuteExpression(false);
+        parameter.setTitle(title);
+        parameter.setDataTitle(title);
+        LOGGER.info("Yida process start request endpoint={} body={}",
+                START_PROCESS_ENDPOINT, JSON.toJSONString(maskSensitive(parameter)));
+
+        Object result = yidaClient.operateData(parameter, YDConf.FORM_OPERATION.start);
+        if (result == null) {
+            throw new IllegalStateException("Yida process start returned an empty result");
+        }
+        return String.valueOf(result);
+    }
+
+    private void validateConfiguration() {
+        require(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        require(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        require(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        require(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+
+    private String requireText(String value, String message) {
+        require(value, message);
+        return value.trim();
+    }
+
+    private Map<String, Object> toYidaFormData(YidaProcessStartRequest request, String title) {
+        Map<String, Object> rawFields = request.getFields();
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        addKnownYidaFields(rawFields, formData);
+
+        Map<String, Object> mainData = objectToMap(rawFields.get("mainData"));
+        formData.put(TITLE_FIELD_ID, title);
+        formData.put(APPLICANT_FIELD_ID, Collections.singletonList(request.getUserId()));
+        formData.put(FORM_TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, COMPANY_FIELD_ID, mainData.get("gs"));
+        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(mainData.get("rq")));
+        putIfNotBlank(formData, REGION_FIELD_ID, toRegionName(firstNonBlank(mainData.get("ent"), mainData.get("gs"))));
+        putIfNotBlank(formData, T100_ID_FIELD_ID, mainData.get("t100id"));
+        putIfNotBlank(formData, T100_DEPARTMENT_FIELD_ID, firstNonBlank(mainData.get("t100bm"), mainData.get("bm")));
+
+        List<Map<String, Object>> rows = toDetailRows(rawFields.get("detailData"));
+        if (!rows.isEmpty()) {
+            formData.put(DETAIL_TABLE_FIELD_ID, rows);
+        }
+        return formData;
+    }
+
+    private void addKnownYidaFields(Map<String, Object> rawFields, Map<String, Object> formData) {
+        for (Map.Entry<String, Object> entry : rawFields.entrySet()) {
+            if (entry.getKey() != null && entry.getKey().contains("Field_")) {
+                formData.put(entry.getKey(), entry.getValue());
+            }
+        }
+    }
+
+    private List<Map<String, Object>> toDetailRows(Object detailData) {
+        Object rowSource = detailData;
+        if (detailData instanceof Map) {
+            Map<String, Object> detailMap = objectToMap(detailData);
+            Object wrappedRows = detailMap.get("detailData");
+            if (wrappedRows != null) {
+                rowSource = wrappedRows;
+            }
+        }
+
+        List<?> sourceRows;
+        if (rowSource instanceof List) {
+            sourceRows = (List<?>) rowSource;
+        } else if (rowSource == null) {
+            sourceRows = Collections.emptyList();
+        } else {
+            sourceRows = Collections.singletonList(rowSource);
+        }
+
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Object sourceRow : sourceRows) {
+            Map<String, Object> source = objectToMap(sourceRow);
+            if (source.isEmpty()) {
+                continue;
+            }
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfNotBlank(row, DETAIL_PRICE_NO_FIELD_ID, source.get("hjdh"));
+            putIfNotBlank(row, DETAIL_SUPPLIER_CODE_FIELD_ID, source.get("gysbh"));
+            putIfNotBlank(row, DETAIL_SUPPLIER_NAME_FIELD_ID, source.get("gysmc"));
+            putIfNotBlank(row, DETAIL_T100_ID_FIELD_ID, source.get("t100id"));
+            putIfNotBlank(row, DETAIL_REGION_FIELD_ID, toRegionName(source.get("ent")));
+            putIfNotBlank(row, DETAIL_LANGUAGE_FIELD_ID, source.get("yy"));
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private Map<String, Object> objectToMap(Object value) {
+        if (value == null) {
+            return Collections.emptyMap();
+        }
+        if (value instanceof Map) {
+            return copyMap((Map<?, ?>) value);
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
+    }
+
+    private Object firstNonBlank(Object first, Object second) {
+        return isBlank(first) ? second : first;
+    }
+
+    private String toRegionName(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.isEmpty()) {
+            return null;
+        }
+        if ("87".equals(text) || "ML".equalsIgnoreCase(text) || "zh_CN".equalsIgnoreCase(text)) {
+            return "明磊";
+        }
+        if ("VN".equalsIgnoreCase(text) || "vi_VN".equalsIgnoreCase(text) || "越南".equals(text)) {
+            return "越南";
+        }
+        return text;
+    }
+
+    private Long toDateMillis(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.isEmpty()) {
+            return null;
+        }
+        if (text.matches("\\d+")) {
+            return Long.valueOf(text);
+        }
+        return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+    }
+
+    private void putIfNotBlank(Map<String, Object> target, String fieldId, Object value) {
+        if (!isBlank(value)) {
+            target.put(fieldId, value);
+        }
+    }
+
+    private boolean isBlank(Object value) {
+        return value == null || String.valueOf(value).trim().isEmpty();
+    }
+
+    private Map<String, Object> maskSensitive(YidaOperationParam parameter) {
+        Map<String, Object> body = JSON.parseObject(JSON.toJSONString(parameter));
+        if (body.containsKey("systemToken")) {
+            body.put("systemToken", "******");
+        }
+        return body;
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

+ 392 - 0
src/main/java/com/malk/minglei/service/impl/yida/CustomerReleaseFormPushHandler.java

@@ -0,0 +1,392 @@
+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;
+
+@Service
+public class CustomerReleaseFormPushHandler implements YidaFormPushHandler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(CustomerReleaseFormPushHandler.class);
+    private static final String START_PROCESS_ENDPOINT = "/v1.0/yida/processes/instances/start";
+    public static final String TYPE = "axmt140";
+    private static final String FORM_NAME = "customerRelease";
+    private static final String FORM_UUID = "FORM-3BA9FE18494F4FDD85FE6ABF7DE741BAE30C";
+    private static final String PROCESS_CODE = "TPROC--6TG66381N8G8Y7L9GBEEY7QVXTUY2LBBGZ7TMU";
+    private static final String DEFAULT_ORIGINATOR_USER_ID = "41603502201288023";
+
+    private static final String TITLE_FIELD_ID = "textField_mtv1tgg1";
+    private static final String APPLICANT_FIELD_ID = "employeeField_msh186rr";
+    private static final String COMPANY_FIELD_ID = "selectField_mtv7uxgd";
+    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 RELEASE_NO_FIELD_ID = "textField_mt7zi4fy";
+    private static final String RELEASE_DESC_FIELD_ID = "textField_mt7zi4g0";
+    private static final String NO_DELIVERY_REASON_FIELD_ID = "textField_mt7zi4fz";
+
+    private static final String DETAIL_TABLE_FIELD_ID = "tableField_msh186sv";
+    private static final String DETAIL_SEQ_FIELD_ID = "textField_mt7zi4g1";
+    private static final String DETAIL_NOTICE_NO_FIELD_ID = "textField_mt7zi4g2";
+    private static final String DETAIL_NOTICE_DATE_FIELD_ID = "dateField_mt7zi4g3";
+    private static final String DETAIL_CUSTOMER_CODE_FIELD_ID = "textField_mt6ur4w1";
+    private static final String DETAIL_CUSTOMER_NAME_FIELD_ID = "textField_mt7zi4g4";
+    private static final String DETAIL_TAX_AMOUNT_FIELD_ID = "numberField_mt7zi4g5";
+    private static final String DETAIL_SITE_CREDIT_FIELD_ID = "numberField_mt7zi4g6";
+    private static final String DETAIL_GROUP_CREDIT_FIELD_ID = "numberField_mt7zi4g7";
+    private static final String DETAIL_SHORT_REMARK_FIELD_ID = "textField_mt6ur4w8";
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties applications;
+
+    public CustomerReleaseFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                          DDConf dingTalkConfiguration,
+                                          YidaApplicationsProperties applications) {
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.applications = applications;
+    }
+
+    @Override
+    public List<String> getTypes() {
+        return Collections.singletonList(TYPE);
+    }
+
+    @Override
+    public String push(ErpYidaPushRequest request) {
+        validateConfiguration();
+
+        String operatorUserId = resolveOperatorUserId(request);
+        String appType = requireText(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        String systemToken = requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+        Map<String, Object> formData = toYidaFormData(request, operatorUserId);
+        String title = requireText(toText(formData.get(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);
+        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)));
+
+        DDR_New response;
+        try {
+            response = 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));
+        }
+        return extractProcessInstanceId(response);
+    }
+
+    private Map<String, Object> toYidaFormData(ErpYidaPushRequest request, String operatorUserId) {
+        Map<String, Object> main = objectToMap(request.getMainData());
+        putIfNotBlank(main, "bm", request.getBm());
+        putIfNotBlank(main, "gs", request.getGs());
+        putIfNotBlank(main, "tjr", request.getTjr());
+        Map<String, Object> detailWrapper = objectToMap(request.getDetailData());
+        List<Map<String, Object>> detailRows = toMapRows(firstNonBlank(detailWrapper.get("detailData1"), detailWrapper.get("detailData")));
+        String docNo = toText(value(main, "xmfxdocno"));
+        String title = toText(firstNonBlank(value(main, "title", "dataTitle"), docNo == null ? null : "customerRelease-" + docNo));
+
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        putIfNotBlank(formData, TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, APPLICANT_FIELD_ID, toStringList(operatorUserId, null));
+        putIfNotBlank(formData, COMPANY_FIELD_ID, firstNonBlank(value(main, "gs", "applyCompany", "company"), value(main, "xmfxent")));
+        putIfNotBlank(formData, DEPARTMENT_FIELD_ID, toStringList(value(main, "bm", "applyDepartment", "department", "deptId"), null));
+        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(firstNonBlank(value(main, "rq", "applyDate"), value(main, "xmfxdocdt"))));
+        putIfNotBlank(formData, FORM_TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, RELEASE_NO_FIELD_ID, value(main, "xmfxdocno"));
+        putIfNotBlank(formData, RELEASE_DESC_FIELD_ID, value(main, "xmfx003"));
+        putIfNotBlank(formData, NO_DELIVERY_REASON_FIELD_ID, value(main, "xmfxud001"));
+
+        List<Map<String, Object>> rows = toDetailRows(detailRows);
+        if (!rows.isEmpty()) {
+            formData.put(DETAIL_TABLE_FIELD_ID, rows);
+        }
+        copyYidaFieldValues(main, formData);
+        return formData;
+    }
+
+    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) {
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfNotBlank(row, DETAIL_SEQ_FIELD_ID, value(source, "xmfyseq"));
+            putIfNotBlank(row, DETAIL_NOTICE_NO_FIELD_ID, value(source, "xmfy002"));
+            putIfNotBlank(row, DETAIL_NOTICE_DATE_FIELD_ID, toDateMillis(value(source, "xmfy003")));
+            putIfNotBlank(row, DETAIL_CUSTOMER_CODE_FIELD_ID, value(source, "xmfy004"));
+            putIfNotBlank(row, DETAIL_CUSTOMER_NAME_FIELD_ID, value(source, "pmaal004"));
+            putIfNotBlank(row, DETAIL_TAX_AMOUNT_FIELD_ID, toDecimal(value(source, "xmfy005")));
+            putIfNotBlank(row, DETAIL_SITE_CREDIT_FIELD_ID, toDecimal(value(source, "xmfy006")));
+            putIfNotBlank(row, DETAIL_GROUP_CREDIT_FIELD_ID, toDecimal(value(source, "xmfy008")));
+            putIfNotBlank(row, DETAIL_SHORT_REMARK_FIELD_ID, value(source, "xmfy007"));
+            copyYidaFieldValues(source, row);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<Map<String, Object>> toMapRows(Object value) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Object item : toList(value)) {
+            Map<String, Object> row = objectToMap(item);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<?> toList(Object value) {
+        if (value instanceof List) {
+            return (List<?>) value;
+        }
+        if (value instanceof Iterable) {
+            List<Object> result = new ArrayList<Object>();
+            for (Object item : (Iterable<?>) value) {
+                result.add(item);
+            }
+            return result;
+        }
+        return value == null ? Collections.emptyList() : Collections.singletonList(value);
+    }
+
+    private List<String> toStringList(Object value, String fallback) {
+        Object actualValue = isBlank(value) ? fallback : value;
+        if (actualValue == null) {
+            return Collections.emptyList();
+        }
+        if (actualValue instanceof Iterable) {
+            List<String> result = new ArrayList<String>();
+            for (Object item : (Iterable<?>) actualValue) {
+                if (!isBlank(item)) {
+                    result.add(String.valueOf(item).trim());
+                }
+            }
+            return result;
+        }
+        return Collections.singletonList(String.valueOf(actualValue).trim());
+    }
+
+    private BigDecimal toDecimal(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        if (value instanceof Number) {
+            return new BigDecimal(String.valueOf(value));
+        }
+        return new BigDecimal(String.valueOf(value).trim());
+    }
+
+    private Long toDateMillis(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.matches("\\d+")) {
+            return Long.valueOf(text);
+        }
+        return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+    }
+
+    private Object value(Map<String, Object> source, String firstKey, String... otherKeys) {
+        Object value = source.get(firstKey);
+        if (!isBlank(value)) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = source.get(key);
+            if (!isBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Object firstNonBlank(Object first, Object second) {
+        return isBlank(first) ? second : first;
+    }
+
+    private void putIfNotBlank(Map<String, Object> target, String fieldId, Object value) {
+        if (!isBlank(value)) {
+            target.put(fieldId, value);
+        }
+    }
+
+    private void copyYidaFieldValues(Map<String, Object> source, Map<String, Object> target) {
+        for (Map.Entry<String, Object> entry : source.entrySet()) {
+            if (entry.getKey() != null && entry.getKey().contains("Field_") && !isBlank(entry.getValue())) {
+                target.put(entry.getKey(), entry.getValue());
+            }
+        }
+    }
+
+    private Map<String, Object> objectToMap(Object value) {
+        if (value == null) {
+            return Collections.emptyMap();
+        }
+        if (value instanceof Map) {
+            return copyMap((Map<?, ?>) value);
+        }
+        if (value instanceof ErpYidaPushMainData) {
+            ErpYidaPushMainData mainData = (ErpYidaPushMainData) value;
+            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;
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
+    }
+
+    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 resolveOperatorUserId(ErpYidaPushRequest request) {
+        if (!isBlank(request.getOriginatorUserId())) {
+            return request.getOriginatorUserId().trim();
+        }
+        Map<String, Object> main = objectToMap(request.getMainData());
+        Object operator = firstNonBlank(value(main, "originatorUserId", "userId"), value(main, "applicant"));
+        return String.valueOf(isBlank(operator) ? DEFAULT_ORIGINATOR_USER_ID : firstItem(operator)).trim();
+    }
+
+    private Object firstItem(Object value) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                if (!isBlank(item)) {
+                    return item;
+                }
+            }
+            return null;
+        }
+        return value;
+    }
+
+    private String toText(Object value) {
+        return isBlank(value) ? null : String.valueOf(value);
+    }
+
+    private Map<String, Object> maskSensitive(Map<String, Object> source) {
+        Map<String, Object> body = new LinkedHashMap<String, Object>(source);
+        if (body.containsKey("systemToken")) {
+            body.put("systemToken", "******");
+        }
+        return body;
+    }
+
+    private String extractProcessInstanceId(DDR_New response) {
+        if (response == null) {
+            throw new McException("YIDA_NULL_RESPONSE", "Yida process start failed: empty response");
+        }
+        LOGGER.info("Yida process start response formName={} success={} code={} result={} instanceId={} formInstId={}",
+                FORM_NAME, response.isSuccess(), response.getCode(), response.getResult(), response.getInstanceId(), response.getFormInstId());
+
+        Object result = firstNonBlank(firstNonBlank(response.getResult(), response.getInstanceId()), response.getFormInstId());
+        if (!isBlank(result)) {
+            return String.valueOf(result);
+        }
+        if (response.isSuccess()) {
+            return "SUCCESS";
+        }
+        String code = defaultText(response.getCode(), "YIDA_ERROR");
+        String message = defaultText(response.getMessage(), "Yida process start failed: no process instance id");
+        throw new McException(code, "Yida API [startProcess] failed: " + message);
+    }
+
+    private String defaultText(String value, String fallback) {
+        return value == null || value.trim().isEmpty() ? fallback : value.trim();
+    }
+
+    private String rootMessage(Throwable exception) {
+        Throwable current = exception;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        String message = current.getMessage();
+        if (message == null || message.trim().isEmpty()) {
+            message = exception.getMessage();
+        }
+        if (message == null || message.trim().isEmpty()) {
+            message = current.getClass().getSimpleName();
+        }
+        return message.trim();
+    }
+
+    private void validateConfiguration() {
+        require(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        require(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        require(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        require(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+
+    private String requireText(String value, String message) {
+        require(value, message);
+        return value.trim();
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

+ 29 - 0
src/main/java/com/malk/minglei/service/impl/yida/DingTalkYidaProcessOpenApiClient.java

@@ -0,0 +1,29 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+/**
+ * 使用钉钉 OpenAPI 发起宜搭流程。
+ */
+@Service
+public class DingTalkYidaProcessOpenApiClient implements YidaProcessOpenApiClient {
+
+    private static final String YIDA_OPEN_API_BASE_URL = "https://api.dingtalk.com/v1.0/yida";
+    private static final String START_PROCESS_ENDPOINT = "/processes/instances/start";
+
+    private final DDClient dingTalkClient;
+
+    public DingTalkYidaProcessOpenApiClient(DDClient dingTalkClient) {
+        this.dingTalkClient = dingTalkClient;
+    }
+
+    @Override
+    public DDR_New startProcess(Map<String, Object> body) {
+        return DDR_New.doPost(YIDA_OPEN_API_BASE_URL + START_PROCESS_ENDPOINT, dingTalkClient.initTokenHeader(), null, body);
+    }
+}

+ 296 - 0
src/main/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImpl.java

@@ -0,0 +1,296 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.dto.ErpYidaPushMainData;
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.dto.YidaProcessStartRequest;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import com.malk.minglei.service.yida.ErpYidaPushService;
+import com.malk.minglei.service.yida.YidaFormPushHandler;
+import com.malk.minglei.service.yida.YidaProcessService;
+import com.malk.server.common.McR;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * ERP 推送宜搭数据接收服务实现。
+ */
+@Service
+public class ErpYidaPushServiceImpl implements ErpYidaPushService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ErpYidaPushServiceImpl.class);
+    private static final String DEFAULT_DATA_TITLE = "包材核价单";
+
+    private final Logger logger;
+    private final ObjectMapper objectMapper;
+    private final YidaProcessService yidaProcessService;
+    private final Map<String, YidaFormPushHandler> formPushHandlers;
+    private final EmployeeRosterService employeeRosterService;
+
+    /**
+     * 使用应用日志记录器创建服务。
+     */
+    @Autowired
+    public ErpYidaPushServiceImpl(YidaProcessService yidaProcessService,
+                                  List<YidaFormPushHandler> formPushHandlers,
+                                  EmployeeRosterService employeeRosterService) {
+        this(LOGGER, yidaProcessService, formPushHandlers, employeeRosterService);
+    }
+
+    ErpYidaPushServiceImpl(Logger logger, YidaProcessService yidaProcessService) {
+        this(logger, yidaProcessService, Collections.<YidaFormPushHandler>emptyList(), null);
+    }
+
+    ErpYidaPushServiceImpl(Logger logger, YidaProcessService yidaProcessService, List<YidaFormPushHandler> formPushHandlers) {
+        this(logger, yidaProcessService, formPushHandlers, null);
+    }
+
+    ErpYidaPushServiceImpl(Logger logger,
+                           YidaProcessService yidaProcessService,
+                           List<YidaFormPushHandler> formPushHandlers,
+                           EmployeeRosterService employeeRosterService) {
+        this.logger = logger;
+        this.objectMapper = new ObjectMapper();
+        this.yidaProcessService = yidaProcessService;
+        this.formPushHandlers = indexFormPushHandlers(formPushHandlers);
+        this.employeeRosterService = employeeRosterService;
+    }
+
+    /**
+     * 将 ERP 入参转换为统一流程请求,并按 type 路由到宜搭处理器。
+     *
+     * @param request ERP 推送请求
+     * @return 固定成功响应
+     */
+    @Override
+    public McR push(ErpYidaPushRequest request) {
+        validateDetailDataShape(request.getDetailData());
+        applyTopLevelCommonFields(request);
+        YidaFormPushHandler formPushHandler = formPushHandlers.get(normalizeType(request.getType()));
+        String originatorUserId = resolveOriginatorUserId(request, formPushHandler != null);
+        request.setOriginatorUserId(originatorUserId);
+        applyMainDataDefaults(request, originatorUserId);
+        try {
+            logger.info("ERP Yida push request: {}", objectMapper.writeValueAsString(request));
+        } catch (JsonProcessingException exception) {
+            logger.warn("ERP Yida push request could not be serialized", exception);
+        }
+        if (formPushHandler != null) {
+            String formInstanceId = formPushHandler.push(request);
+            logger.info("ERP Yida push dispatched formInstanceId: {}", formInstanceId);
+        } else {
+            String processInstanceId = yidaProcessService.startProcess(toProcessStartRequest(request, originatorUserId));
+            logger.info("ERP Yida push dispatched processInstanceId: {}", processInstanceId);
+        }
+
+        McR response = McR.success();
+        try {
+            logger.info("ERP Yida push response: {}", objectMapper.writeValueAsString(response));
+        } catch (JsonProcessingException exception) {
+            logger.warn("ERP Yida push response could not be serialized", exception);
+        }
+        return response;
+    }
+
+    private void validateDetailDataShape(Object detailData) {
+        if (!(detailData instanceof Map)) {
+            throw new IllegalArgumentException("ERP Yida push detailData must be an object containing detailData array");
+        }
+        if (!hasDetailDataRows((Map<?, ?>) detailData)) {
+            throw new IllegalArgumentException("ERP Yida push detailData.detailData must be an array");
+        }
+    }
+
+    private boolean hasDetailDataRows(Map<?, ?> detailData) {
+        for (Map.Entry<?, ?> entry : detailData.entrySet()) {
+            Object key = entry.getKey();
+            if (key != null && String.valueOf(key).matches("detailData\\d*") && entry.getValue() instanceof List) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private YidaProcessStartRequest toProcessStartRequest(ErpYidaPushRequest request, String originatorUserId) {
+        YidaProcessStartRequest yidaRequest = new YidaProcessStartRequest();
+        yidaRequest.setType(request.getType());
+        yidaRequest.setUserId(originatorUserId);
+        yidaRequest.setDataTitle(DEFAULT_DATA_TITLE);
+
+        Map<String, Object> fields = new LinkedHashMap<String, Object>();
+        fields.put("mainData", request.getMainData());
+        fields.put("detailData", request.getDetailData());
+        yidaRequest.setFields(fields);
+        return yidaRequest;
+    }
+
+    private void applyTopLevelCommonFields(ErpYidaPushRequest request) {
+        ErpYidaPushMainData mainData = request.getMainData();
+        if (mainData == null) {
+            return;
+        }
+        if (!isBlank(request.getBm())) {
+            mainData.setBm(request.getBm());
+        }
+        if (!isBlank(request.getGs())) {
+            mainData.setGs(request.getGs());
+        }
+        if (!isBlank(request.getTjr())) {
+            mainData.setTjr(request.getTjr());
+        }
+    }
+
+    private String resolveOriginatorUserId(ErpYidaPushRequest request, boolean allowBlankEmployeeNo) {
+        ErpYidaPushMainData mainData = request.getMainData();
+        if (mainData == null) {
+            throw new IllegalArgumentException("ERP Yida push mainData must not be null");
+        }
+        String employeeNo = firstNonBlank(request.getTjr(), mainData.getTjr());
+        if (employeeNo == null) {
+            if (allowBlankEmployeeNo) {
+                return null;
+            }
+            throw new IllegalArgumentException("ERP Yida push mainData.tjr must not be blank");
+        }
+        if (employeeRosterService == null) {
+            throw new IllegalStateException("Employee roster service is not configured");
+        }
+
+        EmployeeRosterInfo employee = employeeRosterService.findByEmployeeNo(employeeNo);
+        if (employee == null) {
+            throw new IllegalArgumentException("ERP Yida push mainData.tjr employee not found: " + employeeNo);
+        }
+        String dingtalkId = trimToNull(employee.getDingtalkId());
+        if (dingtalkId == null) {
+            throw new IllegalArgumentException("ERP Yida push mainData.tjr employee has no DingTalk userId: " + employeeNo);
+        }
+        return dingtalkId;
+    }
+
+    private String firstNonBlank(String first, String second) {
+        String firstText = trimToNull(first);
+        return firstText == null ? trimToNull(second) : firstText;
+    }
+
+    private void applyMainDataDefaults(ErpYidaPushRequest request, String originatorUserId) {
+        ErpYidaPushMainData mainData = request.getMainData();
+        Map<String, Object> wrapper = copyMap((Map<?, ?>) request.getDetailData());
+
+        for (Map.Entry<String, Object> entry : new ArrayList<Map.Entry<String, Object>>(wrapper.entrySet())) {
+            if (entry.getKey() == null || !entry.getKey().matches("detailData\\d*") || !(entry.getValue() instanceof List)) {
+                continue;
+            }
+            List<Object> normalizedRows = new ArrayList<Object>();
+            for (Object row : (List<?>) entry.getValue()) {
+                if (row instanceof Map) {
+                    Map<String, Object> normalizedRow = copyMap((Map<?, ?>) row);
+                    applyMainDataDefaults(normalizedRow, mainData, originatorUserId);
+                    normalizedRows.add(normalizedRow);
+                } else {
+                    normalizedRows.add(row);
+                }
+            }
+            wrapper.put(entry.getKey(), normalizedRows);
+        }
+
+        request.setDetailData(wrapper);
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
+    }
+
+    private void applyMainDataDefaults(Map<String, Object> target, ErpYidaPushMainData mainData, String originatorUserId) {
+        putIfMissing(target, "originatorUserId", originatorUserId);
+        putIfMissing(target, "userId", originatorUserId);
+        putIfMissing(target, "applicant", Collections.singletonList(originatorUserId));
+        putIfMissing(target, "rq", mainData.getRq());
+        putIfMissing(target, "applyDate", mainData.getRq());
+        putIfMissing(target, "date", mainData.getRq());
+        putIfMissing(target, "bm", mainData.getBm());
+        putIfMissing(target, "applyDepartment", toSingletonList(mainData.getBm()));
+        putIfMissing(target, "department", toSingletonList(mainData.getBm()));
+        putIfMissing(target, "deptId", toSingletonList(mainData.getBm()));
+        putIfMissing(target, "gs", mainData.getGs());
+        putIfMissing(target, "t100id", mainData.getT100id());
+        putIfMissing(target, "t100Id", mainData.getT100id());
+        putIfMissing(target, "ent", mainData.getEnt());
+        putIfMissing(target, "t100bm", mainData.getT100bm());
+        putIfMissing(target, "t100Department", mainData.getT100bm());
+    }
+
+    private void putIfMissing(Map<String, Object> target, String key, Object value) {
+        if (isBlank(target.get(key)) && !isBlank(value)) {
+            target.put(key, value);
+        }
+    }
+
+    private List<String> toSingletonList(String value) {
+        String text = trimToNull(value);
+        return text == null ? null : Collections.singletonList(text);
+    }
+
+    private String trimToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String text = value.trim();
+        return text.isEmpty() ? null : text;
+    }
+
+    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 Map<String, YidaFormPushHandler> indexFormPushHandlers(List<YidaFormPushHandler> handlers) {
+        if (handlers == null || handlers.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<String, YidaFormPushHandler> result = new LinkedHashMap<String, YidaFormPushHandler>();
+        for (YidaFormPushHandler handler : handlers) {
+            if (handler == null || handler.getTypes() == null) {
+                continue;
+            }
+            for (String type : handler.getTypes()) {
+                String normalizedType = normalizeType(type);
+                if (result.put(normalizedType, handler) != null) {
+                    throw new IllegalStateException("Duplicate Yida form push handler type: " + normalizedType);
+                }
+            }
+        }
+        return Collections.unmodifiableMap(result);
+    }
+
+    private String normalizeType(String type) {
+        if (type == null || type.trim().isEmpty()) {
+            throw new IllegalArgumentException("ERP Yida push type must not be blank");
+        }
+        return type.trim().toUpperCase(Locale.ROOT);
+    }
+}

+ 357 - 0
src/main/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImpl.java

@@ -0,0 +1,357 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.MingleiDocumentQueryRequest;
+import com.malk.minglei.service.yida.MingleiDocumentService;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+public class MingleiDocumentServiceImpl implements MingleiDocumentService {
+
+    private static final String MINGLEI_DOCUMENT_FORM_UUID = "FORM-8F527F696C5B4AF7AB7A53979527FBA23USO";
+    private static final String DEPARTMENT_ID_FIELD_ID = "textField_msztwhs2";
+    private static final String DEPARTMENT_NAME_FIELD_ID = "textField_mszr6tuz";
+    private static final String FIRST_LEVEL_DEPARTMENT_MANAGER_FIELD_ID = "employeeField_mszdqrl8";
+    private static final String SECOND_LEVEL_DEPARTMENT_MANAGER_FIELD_ID = "employeeField_mszdqrl9";
+    private static final String THIRD_LEVEL_DEPARTMENT_MANAGER_FIELD_ID = "employeeField_mszdqrla";
+    private static final String FOURTH_LEVEL_DEPARTMENT_MANAGER_FIELD_ID = "employeeField_mszdqrlb";
+    private static final String DEPARTMENT_LEADER_FIELD_ID = "employeeField_mszdqrl3";
+    private static final String[] DEPARTMENT_MANAGER_FIELD_IDS = {
+            FIRST_LEVEL_DEPARTMENT_MANAGER_FIELD_ID,
+            SECOND_LEVEL_DEPARTMENT_MANAGER_FIELD_ID,
+            THIRD_LEVEL_DEPARTMENT_MANAGER_FIELD_ID,
+            FOURTH_LEVEL_DEPARTMENT_MANAGER_FIELD_ID,
+            DEPARTMENT_LEADER_FIELD_ID
+    };
+    private static final int PRECISE_QUERY_PAGE_SIZE = 100;
+
+    private final YDClient yidaClient;
+    private final DDClient dingTalkClient;
+    private final DDClient_Contacts dingTalkContactsClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties.ApplicationCredentials masterDataConfiguration;
+    private final ObjectMapper objectMapper;
+
+    public MingleiDocumentServiceImpl(@Qualifier("masterDataYidaClient") YDClient yidaClient,
+                                      DDClient dingTalkClient,
+                                      DDClient_Contacts dingTalkContactsClient,
+                                      DDConf dingTalkConfiguration,
+                                      YidaApplicationsProperties applications,
+                                      ObjectMapper objectMapper) {
+        this.yidaClient = yidaClient;
+        this.dingTalkClient = dingTalkClient;
+        this.dingTalkContactsClient = dingTalkContactsClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.masterDataConfiguration = applications.getMasterData();
+        this.objectMapper = objectMapper;
+    }
+
+    /**
+     * 使用明磊表单 UUID 组装查询条件,并通过 malk 的普通表单查询接口返回分页单据。
+     *
+     * @param page 页码,从 1 开始
+     * @param size 每页记录数
+     * @return 包含记录列表、总数和页码的分页查询结果
+     */
+    @Override
+    public Map<String, Object> queryDocuments(int page, int size) {
+        return executeQuery(null, null, page, size);
+    }
+
+    /**
+     * 根据请求参数的优先级生成精准筛选条件:部门编号、用户所属部门、部门名称。
+     *
+     * @param request 包含 user_id、dept_id、dept_name 的查询参数
+     * @return 仅包含各级部门主管和部门分管领导用户 ID 的单层结果
+     */
+    @Override
+    public Map<String, Object> queryDocuments(MingleiDocumentQueryRequest request) {
+        if (request == null) {
+            throw new IllegalArgumentException("Query request must not be null");
+        }
+
+        String departmentId = trimToNull(request.getDeptId());
+        if (departmentId != null) {
+            return extractDepartmentManagers(executeQuery(DEPARTMENT_ID_FIELD_ID, departmentId, 1, PRECISE_QUERY_PAGE_SIZE));
+        }
+
+        String userId = trimToNull(request.getUserId());
+        if (userId != null) {
+            return extractDepartmentManagers(executeQuery(DEPARTMENT_ID_FIELD_ID, resolveDepartmentId(userId), 1, PRECISE_QUERY_PAGE_SIZE));
+        }
+
+        String departmentName = trimToNull(request.getDeptName());
+        if (departmentName != null) {
+            return extractDepartmentManagers(executeQuery(DEPARTMENT_NAME_FIELD_ID, departmentName, 1, PRECISE_QUERY_PAGE_SIZE));
+        }
+
+        throw new IllegalArgumentException("One of dept_id, user_id or dept_name must be provided");
+    }
+
+    /**
+     * 按查询范围读取部门成员 ID,并按成员首次出现顺序去重。
+     *
+     * @param departmentId 钉钉部门 ID
+     * @param type 查询范围,0 表示包含子部门,1 表示仅当前部门
+     * @return 去重后的钉钉 userid 列表
+     */
+    @Override
+    public List<String> listDepartmentUserIds(long departmentId, int type) {
+        String accessToken = dingTalkClient.getAccessToken();
+        require(accessToken, "Unable to obtain DingTalk access token");
+
+        Set<Long> departmentIds = new LinkedHashSet<Long>();
+        departmentIds.add(departmentId);
+
+        // 仅 type 为 0 时查询子部门,type 为 1 时保留当前部门。
+        if (type == 0) {
+            List<Long> childDepartmentIds = dingTalkContactsClient.getDepartmentId_all(accessToken, false, departmentId);
+            if (childDepartmentIds != null) {
+                departmentIds.addAll(childDepartmentIds);
+            }
+        }
+
+        Set<String> userIds = new LinkedHashSet<String>();
+        for (Long currentDepartmentId : departmentIds) {
+            if (currentDepartmentId == null) {
+                continue;
+            }
+            List<String> departmentUserIds = dingTalkContactsClient.listDepartmentUserId(accessToken, currentDepartmentId.longValue());
+            if (departmentUserIds == null) {
+                continue;
+            }
+            for (String userId : departmentUserIds) {
+                String normalizedUserId = trimToNull(userId);
+                if (normalizedUserId != null) {
+                    userIds.add(normalizedUserId);
+                }
+            }
+        }
+        return new ArrayList<String>(userIds);
+    }
+
+    private Map<String, Object> executeQuery(String fieldId, String fieldValue, int page, int size) {
+        validateConfiguration();
+
+        YDParam parameter = new YDParam();
+        parameter.setAppType(masterDataConfiguration.getAppType());
+        parameter.setSystemToken(masterDataConfiguration.getSystemToken());
+        parameter.setFormUuid(MINGLEI_DOCUMENT_FORM_UUID);
+        parameter.setCurrentPage(page);
+        parameter.setPageSize(size);
+        if (fieldId != null) {
+            parameter.setSearchFieldJson(createExactSearchFieldJson(fieldId, fieldValue));
+        }
+
+        DDR_New response = yidaClient.queryData(parameter, YDConf.FORM_QUERY.retrieve_search_form);
+        if (response == null) {
+            throw new IllegalStateException("Yida form query returned an empty response");
+        }
+        response.assertSuccess();
+
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        Object records = response.getRecords();
+        if (records == null) {
+            records = response.getResult();
+        }
+        if (records == null) {
+            records = response.getData();
+        }
+        result.put("records", records);
+        result.put("totalCount", response.getTotalCount());
+        result.put("pageNumber", response.getPageNumber());
+        return result;
+    }
+
+    /**
+     * 从精准查询命中的首条单据提取五个主管字段对应的用户 ID,并直接返回到顶层。
+     *
+     * @param queryResult 宜搭列表查询结果
+     * @return 仅包含一级至四级部门主管和部门分管领导的单层结果
+     */
+    private Map<String, Object> extractDepartmentManagers(Map<String, Object> queryResult) {
+        Map formData = firstFormData(queryResult.get("records"));
+        if (formData == null) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (String fieldId : DEPARTMENT_MANAGER_FIELD_IDS) {
+            result.put(fieldId, firstEmployeeUserId(formData.get(fieldId + "_id")));
+        }
+        return result;
+    }
+
+    private Map firstFormData(Object records) {
+        if (!(records instanceof Iterable)) {
+            return null;
+        }
+        for (Object record : (Iterable<?>) records) {
+            if (!(record instanceof Map)) {
+                return null;
+            }
+            Object formData = ((Map) record).get("formData");
+            return formData instanceof Map ? (Map) formData : null;
+        }
+        return null;
+    }
+
+    private Object firstValue(Object values) {
+        if (values instanceof Iterable) {
+            for (Object value : (Iterable<?>) values) {
+                return value;
+            }
+            return null;
+        }
+        return values;
+    }
+
+    /**
+     * 从宜搭成员字段对应的 ID 数组中提取首个钉钉 userid。
+     *
+     * @param employeeIds 宜搭成员字段对应的 ID 数组
+     * @return 钉钉 userid;字段未填写或无法识别时为 null
+     */
+    private String firstEmployeeUserId(Object employeeIds) {
+        Object employee = firstValue(employeeIds);
+        if (employee == null) {
+            return null;
+        }
+        if (!(employee instanceof Map)) {
+            return toStringValue(employee);
+        }
+
+        Map employeeInfo = (Map) employee;
+        String userId = toStringValue(employeeInfo.get("userid"));
+        if (userId == null) {
+            userId = toStringValue(employeeInfo.get("userId"));
+        }
+        if (userId == null) {
+            userId = toStringValue(employeeInfo.get("user_id"));
+        }
+        return userId == null ? toStringValue(employeeInfo.get("id")) : userId;
+    }
+
+    /**
+     * 调用钉钉通讯录接口读取员工所属部门,并取返回列表中的首个部门编号作为查询条件。
+     *
+     * @param userId 钉钉用户编号
+     * @return 员工所属的部门编号
+     */
+    private String resolveDepartmentId(String userId) {
+        Map userInfo = dingTalkContactsClient.getUserInfoById(dingTalkClient.getAccessToken(), userId);
+        if (userInfo == null) {
+            throw new IllegalArgumentException("No DingTalk user information was found for user_id");
+        }
+
+        String mainDepartmentId = toStringValue(userInfo.get("main_dept_id"));
+        if (mainDepartmentId != null) {
+            return mainDepartmentId;
+        }
+
+        String departmentId = firstDepartmentId(userInfo.get("department"));
+        if (departmentId == null) {
+            departmentId = firstDepartmentId(userInfo.get("dept_id_list"));
+        }
+        if (departmentId == null) {
+            throw new IllegalArgumentException("No department was found for user_id");
+        }
+        return departmentId;
+    }
+
+    /**
+     * 按宜搭普通文本字段的等值查询格式生成 searchFieldJson。
+     *
+     * @param fieldId 宜搭字段 ID
+     * @param value 需要精确匹配的字段值
+     * @return 宜搭搜索条件 JSON
+     */
+    private String createExactSearchFieldJson(String fieldId, String value) {
+        Map<String, String> condition = new LinkedHashMap<String, String>();
+        condition.put("key", fieldId);
+        condition.put("value", value);
+        condition.put("type", "TEXT");
+        condition.put("operator", "eq");
+        condition.put("componentName", "TextField");
+        try {
+            return objectMapper.writeValueAsString(Collections.singletonList(condition));
+        } catch (JsonProcessingException exception) {
+            throw new IllegalStateException("Unable to create Yida search condition", exception);
+        }
+    }
+
+    private String firstDepartmentId(Object departmentIds) {
+        if (departmentIds instanceof Iterable) {
+            for (Object departmentId : (Iterable<?>) departmentIds) {
+                String value = toStringValue(departmentId);
+                if (value != null) {
+                    return value;
+                }
+            }
+            return null;
+        }
+        if (departmentIds != null && departmentIds.getClass().isArray()) {
+            Object[] values = (Object[]) departmentIds;
+            for (Object departmentId : values) {
+                String value = toStringValue(departmentId);
+                if (value != null) {
+                    return value;
+                }
+            }
+            return null;
+        }
+        return toStringValue(departmentIds);
+    }
+
+    private String toStringValue(Object value) {
+        return value == null ? null : trimToNull(String.valueOf(value));
+    }
+
+    private String trimToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String trimmedValue = value.trim();
+        return trimmedValue.isEmpty() ? null : trimmedValue;
+    }
+
+    /**
+     * 校验调用宜搭所需的应用级鉴权配置是否完整。
+     */
+    private void validateConfiguration() {
+        require(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        require(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        require(masterDataConfiguration.getAppType(), "YIDA_MASTER_DATA_APP_TYPE is not configured");
+        require(masterDataConfiguration.getSystemToken(), "YIDA_MASTER_DATA_SYSTEM_TOKEN is not configured");
+    }
+
+    /**
+     * 校验单个配置值非空,避免以不完整配置调用远端接口。
+     *
+     * @param value 待校验配置值
+     * @param message 配置缺失时抛出的错误信息
+     */
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

+ 416 - 0
src/main/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandler.java

@@ -0,0 +1,416 @@
+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;
+
+@Service
+public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(MiscReceiptIssueFormPushHandler.class);
+    private static final String START_PROCESS_ENDPOINT = "/v1.0/yida/processes/instances/start";
+    public static final String TYPE = "cint301";
+    private static final String FORM_NAME = "miscReceiptIssue";
+    private static final String FORM_UUID = "FORM-4B08ADABCDAA45788C043729A855E03ED76G";
+    private static final String PROCESS_CODE = "TPROC--MQD66371C1N84FPSJZO4W79IN4BW2AE7I08TM0";
+    private static final String DEFAULT_ORIGINATOR_USER_ID = "41603502201288023";
+
+    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 DATA_SOURCE_FIELD_ID = "textField_mt7zi4fy";
+    private static final String APPLICANT_NO_FIELD_ID = "textField_mt7zi4g0";
+    private static final String DEPARTMENT_NO_FIELD_ID = "textField_mt80nq6s";
+    private static final String REASON_CODE_FIELD_ID = "textField_mt80nq6t";
+    private static final String REASON_DESC_FIELD_ID = "textField_mt80nq6v";
+    private static final String REMARK_FIELD_ID = "textField_mt7zi4fz";
+
+    private static final String DETAIL_TABLE_FIELD_ID = "tableField_msh186sv";
+    private static final String DETAIL_SEQ_FIELD_ID = "textField_mt7zi4g1";
+    private static final String DETAIL_ORDER_NO_FIELD_ID = "textField_mt80nq6y";
+    private static final String DETAIL_ORDER_SEQ_FIELD_ID = "textField_mt80nq6z";
+    private static final String DETAIL_ITEM_CODE_FIELD_ID = "textField_mt80nq70";
+    private static final String DETAIL_ITEM_NAME_FIELD_ID = "textField_mt80nq71";
+    private static final String DETAIL_SPEC_FIELD_ID = "textField_mt80nq72";
+    private static final String DETAIL_PRODUCT_FEATURE_FIELD_ID = "textField_mt80nq73";
+    private static final String DETAIL_WAREHOUSE_FIELD_ID = "textField_mt80nq74";
+    private static final String DETAIL_LOCATION_FIELD_ID = "textField_mt80nq75";
+    private static final String DETAIL_LOT_NO_FIELD_ID = "textField_mt7zi4g2";
+    private static final String DETAIL_STOCK_FEATURE_FIELD_ID = "textField_mt80nq76";
+    private static final String DETAIL_QUANTITY_FIELD_ID = "numberField_mt80nq77";
+    private static final String DETAIL_UNIT_FIELD_ID = "textField_mt6ur4w1";
+    private static final String DETAIL_REASON_CODE_FIELD_ID = "textField_mt7zi4g4";
+    private static final String DETAIL_REASON_DESC_FIELD_ID = "textField_mt80nq79";
+    private static final String DETAIL_PROJECT_NO_FIELD_ID = "textField_mt6ur4w8";
+    private static final String DETAIL_PROJECT_DESC_FIELD_ID = "textField_mt80nq7a";
+    private static final String DETAIL_SOURCE_DOC_NO_FIELD_ID = "textField_mt80nq7b";
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties applications;
+
+    public MiscReceiptIssueFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                           DDConf dingTalkConfiguration,
+                                           YidaApplicationsProperties applications) {
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.applications = applications;
+    }
+
+    @Override
+    public List<String> getTypes() {
+        return Collections.singletonList(TYPE);
+    }
+
+    @Override
+    public String push(ErpYidaPushRequest request) {
+        validateConfiguration();
+
+        String operatorUserId = resolveOperatorUserId(request);
+        String appType = requireText(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        String systemToken = requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+        Map<String, Object> formData = toYidaFormData(request, operatorUserId);
+        String title = requireText(toText(formData.get(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);
+        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)));
+
+        DDR_New response;
+        try {
+            response = 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));
+        }
+        return extractProcessInstanceId(response);
+    }
+
+    private Map<String, Object> toYidaFormData(ErpYidaPushRequest request, String operatorUserId) {
+        Map<String, Object> main = objectToMap(request.getMainData());
+        putIfNotBlank(main, "bm", request.getBm());
+        putIfNotBlank(main, "gs", request.getGs());
+        putIfNotBlank(main, "tjr", request.getTjr());
+        Map<String, Object> detailWrapper = objectToMap(request.getDetailData());
+        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));
+
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        putIfNotBlank(formData, TITLE_FIELD_ID, title);
+        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));
+        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(firstNonBlank(value(main, "rq", "applyDate"), value(main, "inbadocdt"))));
+        putIfNotBlank(formData, FORM_TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, DATA_SOURCE_FIELD_ID, value(main, "inba006"));
+        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, REMARK_FIELD_ID, value(main, "inba008"));
+
+        List<Map<String, Object>> rows = toDetailRows(detailRows);
+        if (!rows.isEmpty()) {
+            formData.put(DETAIL_TABLE_FIELD_ID, rows);
+        }
+        copyYidaFieldValues(main, formData);
+        return formData;
+    }
+
+    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) {
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfNotBlank(row, DETAIL_SEQ_FIELD_ID, value(source, "inbbseq"));
+            putIfNotBlank(row, DETAIL_ORDER_NO_FIELD_ID, value(source, "inbbua001"));
+            putIfNotBlank(row, DETAIL_ORDER_SEQ_FIELD_ID, value(source, "inbbua002"));
+            putIfNotBlank(row, DETAIL_ITEM_CODE_FIELD_ID, value(source, "inbb001"));
+            putIfNotBlank(row, DETAIL_ITEM_NAME_FIELD_ID, value(source, "imaal003"));
+            putIfNotBlank(row, DETAIL_SPEC_FIELD_ID, value(source, "imaal004"));
+            putIfNotBlank(row, DETAIL_PRODUCT_FEATURE_FIELD_ID, value(source, "inbb002"));
+            putIfNotBlank(row, DETAIL_WAREHOUSE_FIELD_ID, value(source, "inbb007"));
+            putIfNotBlank(row, DETAIL_LOCATION_FIELD_ID, value(source, "inbb008"));
+            putIfNotBlank(row, DETAIL_LOT_NO_FIELD_ID, value(source, "inbb009"));
+            putIfNotBlank(row, DETAIL_STOCK_FEATURE_FIELD_ID, value(source, "inbb003"));
+            putIfNotBlank(row, DETAIL_QUANTITY_FIELD_ID, toDecimal(value(source, "inbb011")));
+            putIfNotBlank(row, DETAIL_UNIT_FIELD_ID, value(source, "inbb010"));
+            putIfNotBlank(row, DETAIL_REASON_CODE_FIELD_ID, value(source, "inbb016"));
+            putIfNotBlank(row, DETAIL_REASON_DESC_FIELD_ID, value(source, "oocql004"));
+            putIfNotBlank(row, DETAIL_PROJECT_NO_FIELD_ID, value(source, "inbb023"));
+            putIfNotBlank(row, DETAIL_PROJECT_DESC_FIELD_ID, value(source, "pjbal003"));
+            putIfNotBlank(row, DETAIL_SOURCE_DOC_NO_FIELD_ID, value(source, "inbb017"));
+            copyYidaFieldValues(source, row);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<Map<String, Object>> toMapRows(Object value) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Object item : toList(value)) {
+            Map<String, Object> row = objectToMap(item);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<?> toList(Object value) {
+        if (value instanceof List) {
+            return (List<?>) value;
+        }
+        if (value instanceof Iterable) {
+            List<Object> result = new ArrayList<Object>();
+            for (Object item : (Iterable<?>) value) {
+                result.add(item);
+            }
+            return result;
+        }
+        return value == null ? Collections.emptyList() : Collections.singletonList(value);
+    }
+
+    private List<String> toStringList(Object value, String fallback) {
+        Object actualValue = isBlank(value) ? fallback : value;
+        if (actualValue == null) {
+            return Collections.emptyList();
+        }
+        if (actualValue instanceof Iterable) {
+            List<String> result = new ArrayList<String>();
+            for (Object item : (Iterable<?>) actualValue) {
+                if (!isBlank(item)) {
+                    result.add(String.valueOf(item).trim());
+                }
+            }
+            return result;
+        }
+        return Collections.singletonList(String.valueOf(actualValue).trim());
+    }
+
+    private BigDecimal toDecimal(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        if (value instanceof Number) {
+            return new BigDecimal(String.valueOf(value));
+        }
+        return new BigDecimal(String.valueOf(value).trim());
+    }
+
+    private Long toDateMillis(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.matches("\\d+")) {
+            return Long.valueOf(text);
+        }
+        return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+    }
+
+    private Object value(Map<String, Object> source, String firstKey, String... otherKeys) {
+        Object value = source.get(firstKey);
+        if (!isBlank(value)) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = source.get(key);
+            if (!isBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Object firstNonBlank(Object first, Object second) {
+        return isBlank(first) ? second : first;
+    }
+
+    private void putIfNotBlank(Map<String, Object> target, String fieldId, Object value) {
+        if (!isBlank(value)) {
+            target.put(fieldId, value);
+        }
+    }
+
+    private void copyYidaFieldValues(Map<String, Object> source, Map<String, Object> target) {
+        for (Map.Entry<String, Object> entry : source.entrySet()) {
+            if (entry.getKey() != null && entry.getKey().contains("Field_") && !isBlank(entry.getValue())) {
+                target.put(entry.getKey(), entry.getValue());
+            }
+        }
+    }
+
+    private Map<String, Object> objectToMap(Object value) {
+        if (value == null) {
+            return Collections.emptyMap();
+        }
+        if (value instanceof Map) {
+            return copyMap((Map<?, ?>) value);
+        }
+        if (value instanceof ErpYidaPushMainData) {
+            ErpYidaPushMainData mainData = (ErpYidaPushMainData) value;
+            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;
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
+    }
+
+    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 resolveOperatorUserId(ErpYidaPushRequest request) {
+        if (!isBlank(request.getOriginatorUserId())) {
+            return request.getOriginatorUserId().trim();
+        }
+        Map<String, Object> main = objectToMap(request.getMainData());
+        Object operator = firstNonBlank(value(main, "originatorUserId", "userId"), value(main, "applicant"));
+        return String.valueOf(isBlank(operator) ? DEFAULT_ORIGINATOR_USER_ID : firstItem(operator)).trim();
+    }
+
+    private Object firstItem(Object value) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                if (!isBlank(item)) {
+                    return item;
+                }
+            }
+            return null;
+        }
+        return value;
+    }
+
+    private String toText(Object value) {
+        return isBlank(value) ? null : String.valueOf(value);
+    }
+
+    private Map<String, Object> maskSensitive(Map<String, Object> source) {
+        Map<String, Object> body = new LinkedHashMap<String, Object>(source);
+        if (body.containsKey("systemToken")) {
+            body.put("systemToken", "******");
+        }
+        return body;
+    }
+
+    private String extractProcessInstanceId(DDR_New response) {
+        if (response == null) {
+            throw new McException("YIDA_NULL_RESPONSE", "Yida process start failed: empty response");
+        }
+        LOGGER.info("Yida process start response formName={} success={} code={} result={} instanceId={} formInstId={}",
+                FORM_NAME, response.isSuccess(), response.getCode(), response.getResult(), response.getInstanceId(), response.getFormInstId());
+
+        Object result = firstNonBlank(firstNonBlank(response.getResult(), response.getInstanceId()), response.getFormInstId());
+        if (!isBlank(result)) {
+            return String.valueOf(result);
+        }
+        if (response.isSuccess()) {
+            return "SUCCESS";
+        }
+        String code = defaultText(response.getCode(), "YIDA_ERROR");
+        String message = defaultText(response.getMessage(), "Yida process start failed: no process instance id");
+        throw new McException(code, "Yida API [startProcess] failed: " + message);
+    }
+
+    private String defaultText(String value, String fallback) {
+        return value == null || value.trim().isEmpty() ? fallback : value.trim();
+    }
+
+    private String rootMessage(Throwable exception) {
+        Throwable current = exception;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        String message = current.getMessage();
+        if (message == null || message.trim().isEmpty()) {
+            message = exception.getMessage();
+        }
+        if (message == null || message.trim().isEmpty()) {
+            message = current.getClass().getSimpleName();
+        }
+        return message.trim();
+    }
+
+    private void validateConfiguration() {
+        require(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        require(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        require(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        require(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+
+    private String requireText(String value, String message) {
+        require(value, message);
+        return value.trim();
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

+ 471 - 0
src/main/java/com/malk/minglei/service/impl/yida/MlWorkOrderCraftPriceFormPushHandler.java

@@ -0,0 +1,471 @@
+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;
+
+/**
+ * 处理 ML-工单工艺工价审定对应的宜搭流程发起。
+ */
+@Service
+public class MlWorkOrderCraftPriceFormPushHandler implements YidaFormPushHandler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(MlWorkOrderCraftPriceFormPushHandler.class);
+    private static final String START_PROCESS_ENDPOINT = "/v1.0/yida/processes/instances/start";
+    private static final String FORM_NAME = "ML-工单工艺工价审定";
+    private static final String FORM_UUID = "FORM-4403B45A7F184CB79088FCDAFD2FD925LZ3K";
+    public static final String TYPE = FORM_UUID;
+    private static final String PROCESS_CODE = "TPROC--WG966BA1VWF8Q1K4OGF8H9KW47RR3ZREH0YSM2";
+    private static final String DEFAULT_ORIGINATOR_USER_ID = "41603502201288023";
+
+    private static final String TITLE_FIELD_ID = "textField_msh27unu";
+    private static final String APPLICANT_FIELD_ID = "employeeField_msh186rr";
+    private static final String COMPANY_CODE_FIELD_ID = "textField_mu1yk6l5";
+    private static final String DEPARTMENT_FIELD_ID = "departmentSelectField_msh186ry";
+    private static final String APPLY_DATE_FIELD_ID = "dateField_msh186rs";
+    private static final String APPLICANT_POSITION_FIELD_ID = "textField_mspqoa00";
+    private static final String MOBILE_FIELD_ID = "textField_mspqoa01";
+    private static final String FORM_TITLE_FIELD_ID = "textField_mspcpz92";
+    private static final String WORK_ORDER_NO_FIELD_ID = "textField_msy0zj5i";
+    private static final String PRODUCTION_ORDER_FIELD_ID = "textField_msy0zj5j";
+    private static final String PRODUCTION_MATERIAL_FIELD_ID = "textField_msy0zj5l";
+    private static final String ITEM_NAME_FIELD_ID = "textField_msy0zj5m";
+    private static final String SPECIFICATION_FIELD_ID = "textField_msy0zj5n";
+    private static final String ASSEMBLY_PACKAGING_FIELD_ID = "textField_msy0zj5o";
+    private static final String PACKAGING_METHOD_FIELD_ID = "associationFormField_mtl8tim0";
+    private static final String FIXED_PRICE_FIELD_ID = "numberField_msy0zj5q";
+    private static final String TOTAL_UNIT_PRICE_FIELD_ID = "numberField_msy0zj5z";
+
+    private static final String PROCESS_TABLE_FIELD_ID = "tableField_msh186sv";
+    private static final String PROCESS_CODE_FIELD_ID = "textField_msy0zj60";
+    private static final String PROCESS_NAME_FIELD_ID = "textField_msy0zj61";
+    private static final String PROCESS_QUANTITY_FIELD_ID = "numberField_msy0zj62";
+    private static final String PROCESS_PRICE_FIELD_ID = "numberField_msy0zj63";
+    private static final String PROCESS_TOTAL_FIELD_ID = "numberField_msy0zj64";
+
+    private static final String MATERIAL_TABLE_FIELD_ID = "tableField_msh186t3";
+    private static final String MATERIAL_SEQ_FIELD_ID = "textField_msy0zj65";
+    private static final String MATERIAL_CODE_FIELD_ID = "textField_msy0zj66";
+    private static final String MATERIAL_NAME_FIELD_ID = "textField_msy0zj67";
+    private static final String MATERIAL_SPECIFICATION_FIELD_ID = "textField_msy0zj68";
+    private static final String MATERIAL_USAGE_FIELD_ID = "textField_msy0zj69";
+    private static final String MATERIAL_T100_ID_FIELD_ID = "textField_msy0zj6b";
+    private static final String MATERIAL_LINE_LEADER_FIELD_ID = "textField_msy0zj6c";
+    private static final String MATERIAL_ENTERPRISE_NO_FIELD_ID = "textField_msy0zj6h";
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties applications;
+
+    public MlWorkOrderCraftPriceFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                                DDConf dingTalkConfiguration,
+                                                YidaApplicationsProperties applications) {
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.applications = applications;
+    }
+
+    @Override
+    public List<String> getTypes() {
+        return Collections.singletonList(TYPE);
+    }
+
+    @Override
+    public String push(ErpYidaPushRequest request) {
+        validateConfiguration();
+
+        String operatorUserId = resolveOperatorUserId(request);
+        String appType = requireText(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        String systemToken = requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+        Map<String, Object> formData = toYidaFormData(request, operatorUserId);
+        String title = requireText(toText(formData.get(TITLE_FIELD_ID)), "Yida process title is not configured");
+        if (isBlank(formData.get(FORM_TITLE_FIELD_ID))) {
+            formData.put(FORM_TITLE_FIELD_ID, title);
+        }
+        String formDataJson = JSON.toJSONString(formData);
+        Map<String, Object> body = new LinkedHashMap<String, Object>();
+        body.put("appType", appType);
+        body.put("systemToken", systemToken);
+        body.put("userId", operatorUserId);
+        body.put("processCode", PROCESS_CODE);
+        body.put("formUuid", FORM_UUID);
+        body.put("formDataJson", formDataJson);
+        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)));
+
+        DDR_New response;
+        try {
+            response = 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", "宜搭流程发起失败: " + rootMessage(exception));
+        }
+        return extractProcessInstanceId(response);
+    }
+
+    private Map<String, Object> toYidaFormData(ErpYidaPushRequest request, String operatorUserId) {
+        ErpYidaPushMainData mainData = request.getMainData();
+        Map<String, Object> detail = toDetailRecord(request.getDetailData());
+
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        putIfNotBlank(formData, TITLE_FIELD_ID, firstNonBlank(value(detail, "标题", "title", "dataTitle"), value(detail, "工单号", "workOrderNo", "gdh")));
+        putIfNotBlank(formData, APPLICANT_FIELD_ID, toStringList(operatorUserId, null));
+        putIfNotBlank(formData, COMPANY_CODE_FIELD_ID, firstNonBlank(mainData == null ? null : mainData.getGs(), value(detail, "申请公司", "applyCompany", "company", "gs", "companyCode")));
+        putIfNotBlank(formData, DEPARTMENT_FIELD_ID, toStringList(firstNonBlank(mainData == null ? null : mainData.getBm(), value(detail, "申请部门", "applyDepartment", "department", "deptId")), null));
+        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(firstNonBlank(value(detail, "申请日期", "applyDate", "date", "rq"), mainData == null ? null : mainData.getRq())));
+        putIfNotBlank(formData, APPLICANT_POSITION_FIELD_ID, value(detail, "申请人职位", "applicantPosition", "position"));
+        putIfNotBlank(formData, MOBILE_FIELD_ID, value(detail, "手机号", "mobile", "phone"));
+        putIfNotBlank(formData, FORM_TITLE_FIELD_ID, firstNonBlank(value(detail, "表单标题", "formTitle"), value(detail, "标题", "title", "dataTitle"), value(detail, "工单号", "workOrderNo", "gdh")));
+        putIfNotBlank(formData, WORK_ORDER_NO_FIELD_ID, value(detail, "工单号", "workOrderNo", "gdh", "workOrderCode"));
+        putIfNotBlank(formData, PRODUCTION_ORDER_FIELD_ID, value(detail, "生产令", "productionOrder", "scl", "productionNo"));
+        putIfNotBlank(formData, PRODUCTION_MATERIAL_FIELD_ID, value(detail, "生产料号", "productionMaterial", "productionItemCode", "sclh"));
+        putIfNotBlank(formData, ITEM_NAME_FIELD_ID, value(detail, "品名", "itemName", "pm", "productName"));
+        putIfNotBlank(formData, SPECIFICATION_FIELD_ID, value(detail, "规格", "specification", "spec", "gg"));
+        putIfNotBlank(formData, ASSEMBLY_PACKAGING_FIELD_ID, value(detail, "组装|包装", "assemblyPackaging", "zzbz", "assemblyOrPackaging"));
+        putStructuredAssociation(formData, PACKAGING_METHOD_FIELD_ID, value(detail, "包装方式", "packagingMethod"));
+        putIfNotBlank(formData, FIXED_PRICE_FIELD_ID, toDecimal(value(detail, "固定工价", "fixedPrice", "gdgj")));
+        putIfNotBlank(formData, TOTAL_UNIT_PRICE_FIELD_ID, toDecimal(value(detail, "总单位工价", "totalUnitPrice", "zdwgj")));
+
+        List<Map<String, Object>> processRows = toProcessRows(firstNonNull(
+                detail.get("明细表1"), detail.get("processData"), detail.get("processList"), detail.get("gongxuData"), detail.get("detailData1"), detail.get("detailData")));
+        if (!processRows.isEmpty()) {
+            formData.put(PROCESS_TABLE_FIELD_ID, processRows);
+        }
+
+        List<Map<String, Object>> materialRows = toMaterialRows(firstNonNull(
+                detail.get("明细表2"), detail.get("materialData"), detail.get("materialList"), detail.get("bomData"), detail.get("bomList"), detail.get("detailData2")));
+        if (!materialRows.isEmpty()) {
+            formData.put(MATERIAL_TABLE_FIELD_ID, materialRows);
+        }
+
+        copyYidaFieldValues(detail, formData);
+        return formData;
+    }
+
+    private List<Map<String, Object>> toProcessRows(Object rowSource) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Object sourceRow : toList(rowSource)) {
+            Map<String, Object> source = objectToMap(sourceRow);
+            if (source.isEmpty()) {
+                continue;
+            }
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfNotBlank(row, PROCESS_CODE_FIELD_ID, value(source, "工序编号", "processCode", "gxbh", "code"));
+            putIfNotBlank(row, PROCESS_NAME_FIELD_ID, value(source, "名称", "processName", "mc", "name"));
+            putIfNotBlank(row, PROCESS_QUANTITY_FIELD_ID, toDecimal(value(source, "数量", "quantity", "sl", "qty")));
+            putIfNotBlank(row, PROCESS_PRICE_FIELD_ID, toDecimal(value(source, "工价", "price", "gj")));
+            putIfNotBlank(row, PROCESS_TOTAL_FIELD_ID, toDecimal(value(source, "合计", "total", "hj", "amount")));
+            copyYidaFieldValues(source, row);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<Map<String, Object>> toMaterialRows(Object rowSource) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Object sourceRow : toList(rowSource)) {
+            Map<String, Object> source = objectToMap(sourceRow);
+            if (source.isEmpty()) {
+                continue;
+            }
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfNotBlank(row, MATERIAL_SEQ_FIELD_ID, value(source, "项次", "seq", "xc"));
+            putIfNotBlank(row, MATERIAL_CODE_FIELD_ID, value(source, "料号", "materialCode", "lh"));
+            putIfNotBlank(row, MATERIAL_NAME_FIELD_ID, value(source, "品名", "materialName", "pm", "name"));
+            putIfNotBlank(row, MATERIAL_SPECIFICATION_FIELD_ID, value(source, "规格", "specification", "spec", "gg"));
+            putIfNotBlank(row, MATERIAL_USAGE_FIELD_ID, value(source, "用量", "usage", "yl"));
+            putIfNotBlank(row, MATERIAL_T100_ID_FIELD_ID, value(source, "t100id", "t100Id"));
+            putIfNotBlank(row, MATERIAL_LINE_LEADER_FIELD_ID, value(source, "车间线长", "lineLeader", "cjxz"));
+            putIfNotBlank(row, MATERIAL_ENTERPRISE_NO_FIELD_ID, value(source, "企业编号", "enterpriseNo", "qybm", "ent"));
+            copyYidaFieldValues(source, row);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private void copyYidaFieldValues(Map<String, Object> source, Map<String, Object> target) {
+        for (Map.Entry<String, Object> entry : source.entrySet()) {
+            if (entry.getKey() != null && entry.getKey().contains("Field_") && !isBlank(entry.getValue())) {
+                target.put(entry.getKey(), entry.getValue());
+            }
+        }
+    }
+
+    private Object value(Map<String, Object> source, String firstKey, String... otherKeys) {
+        Object value = source.get(firstKey);
+        if (!isBlank(value)) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = source.get(key);
+            if (!isBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Object firstNonBlank(Object first, Object second, Object... others) {
+        if (!isBlank(first)) {
+            return first;
+        }
+        if (!isBlank(second)) {
+            return second;
+        }
+        for (Object other : others) {
+            if (!isBlank(other)) {
+                return other;
+            }
+        }
+        return null;
+    }
+
+    private Object firstNonNull(Object first, Object second, Object third, Object fourth, Object fifth, Object sixth) {
+        if (first != null) {
+            return first;
+        }
+        if (second != null) {
+            return second;
+        }
+        if (third != null) {
+            return third;
+        }
+        if (fourth != null) {
+            return fourth;
+        }
+        return fifth == null ? sixth : fifth;
+    }
+
+    private List<?> toList(Object value) {
+        if (value instanceof List) {
+            return (List<?>) value;
+        }
+        if (value instanceof Iterable) {
+            List<Object> result = new ArrayList<Object>();
+            for (Object item : (Iterable<?>) value) {
+                result.add(item);
+            }
+            return result;
+        }
+        return value == null ? Collections.emptyList() : Collections.singletonList(value);
+    }
+
+    private List<String> toStringList(Object value, String fallback) {
+        Object actualValue = isBlank(value) ? fallback : value;
+        if (actualValue == null) {
+            return Collections.emptyList();
+        }
+        if (actualValue instanceof Iterable) {
+            List<String> result = new ArrayList<String>();
+            for (Object item : (Iterable<?>) actualValue) {
+                if (!isBlank(item)) {
+                    result.add(String.valueOf(item).trim());
+                }
+            }
+            return result;
+        }
+        return Collections.singletonList(String.valueOf(actualValue).trim());
+    }
+
+    private BigDecimal toDecimal(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        if (value instanceof Number) {
+            return new BigDecimal(String.valueOf(value));
+        }
+        return new BigDecimal(String.valueOf(value).trim());
+    }
+
+    private Long toDateMillis(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.matches("\\d+")) {
+            return Long.valueOf(text);
+        }
+        return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+    }
+
+    private void putStructuredAssociation(Map<String, Object> target, String fieldId, Object value) {
+        if (value instanceof Map) {
+            target.put(fieldId, Collections.singletonList(value));
+        } else if (value instanceof Iterable) {
+            target.put(fieldId, value);
+        }
+    }
+
+    private void putIfNotBlank(Map<String, Object> target, String fieldId, Object value) {
+        if (!isBlank(value)) {
+            target.put(fieldId, value);
+        }
+    }
+
+    private Map<String, Object> objectToMap(Object value) {
+        if (value == null) {
+            return Collections.emptyMap();
+        }
+        if (value instanceof Map) {
+            return copyMap((Map<?, ?>) value);
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
+    }
+
+    private Map<String, Object> toDetailRecord(Object detailData) {
+        Map<String, Object> wrapper = objectToMap(detailData);
+        Object rows = wrapper.get("detailData");
+        if (!(rows instanceof List) || ((List<?>) rows).isEmpty()) {
+            return wrapper;
+        }
+
+        Map<String, Object> detail = new LinkedHashMap<String, Object>(wrapper);
+        detail.remove("detailData");
+        detail.putAll(objectToMap(((List<?>) rows).get(0)));
+        return detail;
+    }
+
+    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 Map<String, Object> maskSensitive(Map<String, Object> source) {
+        Map<String, Object> body = new LinkedHashMap<String, Object>(source);
+        if (body.containsKey("systemToken")) {
+            body.put("systemToken", "******");
+        }
+        return body;
+    }
+
+    private String extractProcessInstanceId(DDR_New response) {
+        if (response == null) {
+            throw new McException("YIDA_NULL_RESPONSE", "宜搭流程发起失败: 返回为空");
+        }
+        LOGGER.info("Yida process start response formName={} success={} code={} result={} instanceId={} formInstId={}",
+                FORM_NAME, response.isSuccess(), response.getCode(), response.getResult(), response.getInstanceId(), response.getFormInstId());
+
+        Object result = firstNonBlank(response.getResult(), response.getInstanceId(), response.getFormInstId(), response.getData());
+        if (!isBlank(result)) {
+            return String.valueOf(result);
+        }
+        if (response.isSuccess()) {
+            return "SUCCESS";
+        }
+        String code = defaultText(response.getCode(), "YIDA_ERROR");
+        String message = defaultText(response.getMessage(), "宜搭流程发起失败: 返回体未包含流程实例 ID");
+        throw new McException(code, "宜搭接口 [startProcess] 失败: " + message);
+    }
+
+    private String defaultText(String value, String fallback) {
+        return value == null || value.trim().isEmpty() ? fallback : value.trim();
+    }
+
+    private String rootMessage(Throwable exception) {
+        Throwable current = exception;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        String message = current.getMessage();
+        if (message == null || message.trim().isEmpty()) {
+            message = exception.getMessage();
+        }
+        if (message == null || message.trim().isEmpty()) {
+            message = current.getClass().getSimpleName();
+        }
+        return message.trim();
+    }
+
+    private String resolveOperatorUserId(ErpYidaPushRequest request) {
+        if (!isBlank(request.getOriginatorUserId())) {
+            return request.getOriginatorUserId().trim();
+        }
+        Map<String, Object> detail = toDetailRecord(request.getDetailData());
+        Object operator = firstNonBlank(value(detail, "originatorUserId", "userId"), firstItem(value(detail, "申请人", "applicant")));
+        return String.valueOf(isBlank(operator) ? DEFAULT_ORIGINATOR_USER_ID : operator).trim();
+    }
+
+    private String toText(Object value) {
+        return isBlank(value) ? null : String.valueOf(value);
+    }
+
+    private Object firstItem(Object value) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                if (!isBlank(item)) {
+                    return item;
+                }
+            }
+            return null;
+        }
+        return value;
+    }
+
+    private void validateConfiguration() {
+        require(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        require(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        require(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        require(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+
+    private String requireText(String value, String message) {
+        require(value, message);
+        return value.trim();
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

+ 54 - 0
src/main/java/com/malk/minglei/service/impl/yida/YidaProcessServiceImpl.java

@@ -0,0 +1,54 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.minglei.dto.YidaProcessStartRequest;
+import com.malk.minglei.service.yida.YidaProcessHandler;
+import com.malk.minglei.service.yida.YidaProcessService;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * 按请求 type 将统一流程接口分发到对应表单处理器。
+ */
+@Service
+public class YidaProcessServiceImpl implements YidaProcessService {
+
+    private final Map<String, YidaProcessHandler> handlers;
+
+    public YidaProcessServiceImpl(List<YidaProcessHandler> handlers) {
+        Map<String, YidaProcessHandler> handlerMap = new LinkedHashMap<String, YidaProcessHandler>();
+        for (YidaProcessHandler handler : handlers) {
+            String type = normalizeType(handler.getType());
+            if (handlerMap.put(type, handler) != null) {
+                throw new IllegalStateException("Duplicate Yida process handler type: " + type);
+            }
+        }
+        this.handlers = Collections.unmodifiableMap(handlerMap);
+    }
+
+    /**
+     * 按请求 type 选择对应表单处理器并发起流程实例。
+     *
+     * @param request 统一流程发起请求
+     * @return 宜搭返回的流程实例 ID
+     */
+    @Override
+    public String startProcess(YidaProcessStartRequest request) {
+        YidaProcessHandler handler = handlers.get(normalizeType(request.getType()));
+        if (handler == null) {
+            throw new IllegalArgumentException("Unsupported Yida process type: " + request.getType());
+        }
+        return handler.startProcess(request);
+    }
+
+    private String normalizeType(String type) {
+        if (type == null || type.trim().isEmpty()) {
+            throw new IllegalArgumentException("Yida process type must not be blank");
+        }
+        return type.trim().toUpperCase(Locale.ROOT);
+    }
+}

+ 19 - 0
src/main/java/com/malk/minglei/service/port/MingleiPortService.java

@@ -0,0 +1,19 @@
+package com.malk.minglei.service.port;
+
+import com.malk.minglei.dto.MingleiPortRequest;
+
+import java.util.Map;
+
+/**
+ * 定义调用明磊外部单据接口的业务能力。
+ */
+public interface MingleiPortService {
+
+    /**
+     * 将供应商价格审批单(核价)转发到外部 T100 接口,并返回外部响应内容。
+     *
+     * @param request 仅包含 head 和 detail 的本地请求体
+     * @return 外部接口原始响应体
+     */
+    Map<String, Object> submitSupplierPriceApproval(MingleiPortRequest request);
+}

+ 18 - 0
src/main/java/com/malk/minglei/service/yida/ErpYidaPushService.java

@@ -0,0 +1,18 @@
+package com.malk.minglei.service.yida;
+
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.server.common.McR;
+
+/**
+ * ERP 推送宜搭数据接收服务。
+ */
+public interface ErpYidaPushService {
+
+    /**
+     * 记录 ERP 推送数据,当前阶段不调用宜搭。
+     *
+     * @param request ERP 推送请求
+     * @return 固定成功响应
+     */
+    McR push(ErpYidaPushRequest request);
+}

+ 35 - 0
src/main/java/com/malk/minglei/service/yida/MingleiDocumentService.java

@@ -0,0 +1,35 @@
+package com.malk.minglei.service.yida;
+
+import com.malk.minglei.dto.MingleiDocumentQueryRequest;
+
+import java.util.List;
+import java.util.Map;
+
+public interface MingleiDocumentService {
+
+    /**
+     * 分页查询明蕾普通表单的单据实例。
+     *
+     * @param page 页码,从 1 开始
+     * @param size 每页记录数
+     * @return 包含 records、totalCount 和 pageNumber 的分页查询结果
+     */
+    Map<String, Object> queryDocuments(int page, int size);
+
+    /**
+     * 按部门编号、用户编号或部门名称的优先级查询明磊普通表单单据。
+     *
+     * @param request 包含 user_id、dept_id、dept_name 的查询参数
+     * @return 仅包含各级部门主管和部门分管领导用户 ID 的单层结果
+     */
+    Map<String, Object> queryDocuments(MingleiDocumentQueryRequest request);
+
+    /**
+     * 按查询范围获取指定部门中的钉钉用户 ID。
+     *
+     * @param departmentId 钉钉部门 ID
+     * @param type 查询范围,0 表示包含子部门,1 表示仅当前部门
+     * @return 去重后的钉钉 userid 列表
+     */
+    List<String> listDepartmentUserIds(long departmentId, int type);
+}

+ 15 - 0
src/main/java/com/malk/minglei/service/yida/YidaFormPushHandler.java

@@ -0,0 +1,15 @@
+package com.malk.minglei.service.yida;
+
+import com.malk.minglei.dto.ErpYidaPushRequest;
+
+import java.util.List;
+
+/**
+ * 定义 ERP 推送到宜搭普通表单的处理能力。
+ */
+public interface YidaFormPushHandler {
+
+    List<String> getTypes();
+
+    String push(ErpYidaPushRequest request);
+}

+ 25 - 0
src/main/java/com/malk/minglei/service/yida/YidaProcessHandler.java

@@ -0,0 +1,25 @@
+package com.malk.minglei.service.yida;
+
+import com.malk.minglei.dto.YidaProcessStartRequest;
+
+/**
+ * 定义单个宜搭流程表单的处理能力。
+ * 每个表单类型由一个独立实现类维护表单标识、流程编码和业务处理逻辑。
+ */
+public interface YidaProcessHandler {
+
+    /**
+     * 返回当前处理器支持的统一接口 type 值。
+     *
+     * @return 表单类型标识
+     */
+    String getType();
+
+    /**
+     * 根据请求参数发起当前表单对应的宜搭流程。
+     *
+     * @param request 统一流程发起请求
+     * @return 宜搭返回的流程实例 ID
+     */
+    String startProcess(YidaProcessStartRequest request);
+}

+ 13 - 0
src/main/java/com/malk/minglei/service/yida/YidaProcessOpenApiClient.java

@@ -0,0 +1,13 @@
+package com.malk.minglei.service.yida;
+
+import com.malk.server.dingtalk.DDR_New;
+
+import java.util.Map;
+
+/**
+ * 宜搭流程 OpenAPI 客户端。
+ */
+public interface YidaProcessOpenApiClient {
+
+    DDR_New startProcess(Map<String, Object> body);
+}

+ 14 - 0
src/main/java/com/malk/minglei/service/yida/YidaProcessService.java

@@ -0,0 +1,14 @@
+package com.malk.minglei.service.yida;
+
+import com.malk.minglei.dto.YidaProcessStartRequest;
+
+public interface YidaProcessService {
+
+    /**
+     * 使用请求中的 type、发起人、标题和表单字段创建对应的宜搭流程实例。
+     *
+     * @param request 流程发起请求
+     * @return 宜搭返回的流程实例 ID
+     */
+    String startProcess(YidaProcessStartRequest request);
+}

+ 47 - 0
src/main/resources/application.yml

@@ -0,0 +1,47 @@
+server:
+  port: ${SERVER_PORT:80}
+  tomcat:
+    accesslog:
+      enabled: true
+      directory: ${logging.file.path}
+      prefix: access
+      suffix: .log
+      file-date-format: .yyyy-MM-dd
+      pattern: '%t %a "%r" %s %b %D'
+
+spring:
+  application:
+    name: mjava-minglei
+  profiles:
+    active: ${SPRING_PROFILES_ACTIVE:dev}
+
+dingtalk:
+  agent-id: ${DINGTALK_AGENT_ID:4858845168}
+  app-key: ${DINGTALK_APP_KEY:dingpdriyshfwyzxhe9y}
+  app-secret: ${DINGTALK_APP_SECRET:z1YxVWLauwNdns9cyIJEg58Lz0S2XLdxMmbOkk-xISmeHoKdmusyt2xB4N8vpIFj}
+
+minglei:
+  port:
+    supplier-price-approval-url: ${MINGLEI_SUPPLIER_PRICE_APPROVAL_URL:http://10.0.2.60/wtoptst/ws/r/awsp920}
+    supplier-price-approval-key: ${MINGLEI_SUPPLIER_PRICE_APPROVAL_KEY:f5458f5c0f9022db743a7c0710145903}
+  yida:
+    coordination-office:
+      app-type: ${YIDA_COORDINATION_OFFICE_APP_TYPE:APP_RJJL69QUIZVQSV7YC8TL}
+      system-token: ${YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN:2MA66M81HY28S8O9JEQFY4MRACKO39M3UVGSMIV3}
+    master-data:
+      app-type: ${YIDA_MASTER_DATA_APP_TYPE:APP_KI0AOTH7OLFS2UAUHZXI}
+      system-token: ${YIDA_MASTER_DATA_SYSTEM_TOKEN:0E966191H5C8CRUCHOOOL4UMHY7Y2A2K8QTSMDY7}
+
+  employee-roster:
+    datasource:
+      url: ${MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_URL:jdbc:mysql://127.0.0.1:3306/yida?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false}
+      username: ${MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_USERNAME:root}
+      password: ${MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_PASSWORD:Minglei_2026}
+      driver-class-name: ${MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver}
+
+logging:
+  file:
+    path: ${LOGGING_FILE_PATH:logs}
+  level:
+    com.malk.minglei: INFO
+    com.malk.service.aliwork: INFO

+ 17 - 0
src/main/resources/db/employee_roster.sql

@@ -0,0 +1,17 @@
+CREATE TABLE employee_roster (
+  id INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  dingtalk_id VARCHAR(64) DEFAULT NULL COMMENT '钉钉ID',
+  employee_no VARCHAR(64) DEFAULT NULL COMMENT '工号ID',
+  username VARCHAR(64) NOT NULL COMMENT '用户名',
+  job_title VARCHAR(128) DEFAULT NULL COMMENT '职称',
+  job_title_id VARCHAR(64) DEFAULT NULL COMMENT '职称ID',
+  job_grade VARCHAR(32) DEFAULT NULL COMMENT '职等',
+  job_grade_id VARCHAR(64) DEFAULT NULL COMMENT '职等ID',
+  department VARCHAR(128) DEFAULT NULL COMMENT '部门',
+  first_department VARCHAR(128) DEFAULT NULL COMMENT '一级部门',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_dingtalk_id (dingtalk_id),
+  UNIQUE KEY uk_employee_no (employee_no),
+  KEY idx_department (department),
+  KEY idx_first_department (first_department)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工花名册表';

+ 50 - 0
src/main/resources/logback-spring.xml

@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <springProperty name="LOG_PATH" source="logging.file.path" defaultValue="logs"/>
+
+    <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
+
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <appender name="APPLICATION_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_PATH}/application.log</file>
+        <encoder>
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_PATH}/archive/application.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
+            <maxFileSize>20MB</maxFileSize>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>2GB</totalSizeCap>
+        </rollingPolicy>
+    </appender>
+
+    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_PATH}/error.log</file>
+        <encoder>
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>ERROR</level>
+        </filter>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_PATH}/archive/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
+            <maxFileSize>20MB</maxFileSize>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>1GB</totalSizeCap>
+        </rollingPolicy>
+    </appender>
+
+    <root level="INFO">
+        <appender-ref ref="CONSOLE"/>
+        <appender-ref ref="APPLICATION_FILE"/>
+        <appender-ref ref="ERROR_FILE"/>
+    </root>
+</configuration>

File diff suppressed because it is too large
+ 26972 - 0
src/main/resources/static/mjs/mjs.min.js


+ 22 - 0
src/test/java/com/malk/minglei/MjavaMingleiApplicationTest.java

@@ -0,0 +1,22 @@
+package com.malk.minglei;
+
+import com.malk.service.aliwork.YDClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
+class MjavaMingleiApplicationTest {
+
+    @Autowired
+    @Qualifier("coordinationOfficeYidaClient")
+    private YDClient yidaClient;
+
+    @Test
+    void mjavaYidaClientIsAvailable() {
+        assertNotNull(yidaClient);
+    }
+}

+ 58 - 0
src/test/java/com/malk/minglei/controller/EmployeeRosterControllerTest.java

@@ -0,0 +1,58 @@
+package com.malk.minglei.controller;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.dto.EmployeeRosterQueryRequest;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+class EmployeeRosterControllerTest {
+
+    @Test
+    void getUserRosterReturnsRosterInfoFromDatabase() throws Exception {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo info = new EmployeeRosterInfo();
+        info.setId(Integer.valueOf(1));
+        info.setDingtalkId("41603502201288023");
+        info.setEmployeeNo("260423001");
+        info.setUsername("黄涛");
+        info.setJobTitle("初级工程师");
+        info.setJobTitleId("10040");
+        info.setJobGrade("10");
+        info.setJobGradeId("10010");
+        info.setDepartment("钉钉顾问");
+        info.setFirstDepartment("钉钉顾问");
+        given(employeeRosterService.findByUserId("41603502201288023")).willReturn(info);
+
+        EmployeeRosterController controller = new EmployeeRosterController(employeeRosterService);
+
+        EmployeeRosterQueryRequest request = new EmployeeRosterQueryRequest();
+        request.setUserID("41603502201288023");
+        ResponseEntity<ApiResponse<EmployeeRosterInfo>> response = controller.getUserRoster(request);
+        ObjectMapper objectMapper = new ObjectMapper();
+        Map<String, Object> body = objectMapper.readValue(
+                objectMapper.writeValueAsString(response.getBody()),
+                new TypeReference<Map<String, Object>>() {
+                });
+        Map<?, ?> result = (Map<?, ?>) body.get("result");
+
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals(200, body.get("code"));
+        assertEquals(true, body.get("isSuccess"));
+        assertEquals("41603502201288023", result.get("dingtalkId"));
+        assertEquals("初级工程师", result.get("jobTitle"));
+        assertEquals("10040", result.get("jobTitleId"));
+        assertEquals("10", result.get("jobGrade"));
+        assertEquals("10010", result.get("jobGradeId"));
+    }
+}

+ 103 - 0
src/test/java/com/malk/minglei/controller/ErpYidaPushControllerTest.java

@@ -0,0 +1,103 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import com.malk.minglei.service.impl.yida.ErpYidaPushServiceImpl;
+import com.malk.minglei.service.yida.ErpYidaPushService;
+import com.malk.server.common.McException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ErpYidaPushControllerTest {
+
+    private final EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+
+    private final MockMvc mockMvc = MockMvcBuilders
+            .standaloneSetup(new ErpYidaPushController(new ErpYidaPushServiceImpl(
+                    request -> "process-instance-1",
+                    java.util.Collections.emptyList(),
+                    employeeRosterService)))
+            .setControllerAdvice(new GlobalExceptionHandler())
+            .build();
+
+    @BeforeEach
+    void setUp() {
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setDingtalkId("dingtalk-user-001");
+        when(employeeRosterService.findByEmployeeNo("user-001")).thenReturn(employee);
+    }
+
+    @Test
+    void acceptsObjectDetailDataAndReturnsMcRSuccess() throws Exception {
+        String request = "{\"type\":\"cpmp402\",\"mainData\":{\"tjr\":\"user-001\"},"
+                + "\"detailData\":{\"detailData\":[{\"hjdh\":\"HJ-001\",\"customField\":123}]}}";
+
+        mockMvc.perform(post("/api/erp/yida/push")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(request))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    @Test
+    void acceptsTopLevelSubmitterAndReturnsMcRSuccess() throws Exception {
+        String request = "{\"type\":\"cpmp402\",\"bm\":\"123456\",\"gs\":\"ML\",\"tjr\":\"user-001\",\"mainData\":{},"
+                + "\"detailData\":{\"detailData\":[{\"hjdh\":\"HJ-001\",\"customField\":123}]}}";
+
+        mockMvc.perform(post("/api/erp/yida/push")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(request))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    @Test
+    void rejectsRequestWithoutMainDataTjr() throws Exception {
+        String request = "{\"type\":\"cpmp402\",\"mainData\":{},\"detailData\":{\"detailData\":[]}}";
+
+        mockMvc.perform(post("/api/erp/yida/push")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(request))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    void rejectsArrayDetailDataWithoutWrapper() throws Exception {
+        String request = "{\"type\":\"cpmp402\",\"mainData\":{\"tjr\":\"user-001\"},\"detailData\":[]}";
+
+        mockMvc.perform(post("/api/erp/yida/push")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(request))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value("ERP Yida push detailData must be an object containing detailData array"));
+    }
+
+    @Test
+    void returnsYidaClientErrorDetails() throws Exception {
+        ErpYidaPushService service = request -> {
+            throw new McException("DINGTALK_YIDA_ERROR", "上游字段校验失败");
+        };
+        MockMvc errorMockMvc = MockMvcBuilders
+                .standaloneSetup(new ErpYidaPushController(service))
+                .setControllerAdvice(new GlobalExceptionHandler())
+                .build();
+
+        String request = "{\"type\":\"ml_work_order_craft_price\",\"mainData\":{\"tjr\":\"user-001\"},\"detailData\":{\"detailData\":[]}}";
+
+        errorMockMvc.perform(post("/api/erp/yida/push")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(request))
+                .andExpect(status().isBadGateway())
+                .andExpect(jsonPath("$.code").value("DINGTALK_YIDA_ERROR"))
+                .andExpect(jsonPath("$.message").value("上游字段校验失败"));
+    }
+}

+ 46 - 0
src/test/java/com/malk/minglei/controller/YidaProcessControllerTest.java

@@ -0,0 +1,46 @@
+package com.malk.minglei.controller;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.YidaProcessStartRequest;
+import com.malk.minglei.service.yida.MingleiDocumentService;
+import com.malk.minglei.service.yida.YidaProcessService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * 验证宜搭流程接口的 HTTP 状态和统一响应体结构。
+ */
+class YidaProcessControllerTest {
+
+    /**
+     * 验证流程发起成功时将实例 ID 写入统一响应的 result 字段。
+     */
+    @Test
+    void startReturnsProcessInstanceIdInUnifiedResponse() throws Exception {
+        YidaProcessService yidaProcessService = mock(YidaProcessService.class);
+        given(yidaProcessService.startProcess(any(YidaProcessStartRequest.class))).willReturn("process-instance-1");
+        YidaProcessController controller = new YidaProcessController(yidaProcessService, mock(MingleiDocumentService.class));
+
+        ResponseEntity<ApiResponse<Map<String, String>>> response = controller.start(new YidaProcessStartRequest());
+        ObjectMapper objectMapper = new ObjectMapper();
+        Map<String, Object> body = objectMapper.readValue(
+                objectMapper.writeValueAsString(response.getBody()),
+                new TypeReference<Map<String, Object>>() {
+                });
+
+        assertEquals(HttpStatus.CREATED, response.getStatusCode());
+        assertEquals(200, body.get("code"));
+        assertEquals(true, body.get("isSuccess"));
+        assertEquals("process-instance-1", ((Map<?, ?>) body.get("result")).get("processInstanceId"));
+    }
+}

+ 28 - 0
src/test/java/com/malk/minglei/dto/ApiResponseTest.java

@@ -0,0 +1,28 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ApiResponseTest {
+
+    @Test
+    void serializesWithTheUnifiedResponseFields() throws Exception {
+        ObjectMapper objectMapper = new ObjectMapper();
+
+        String json = objectMapper.writeValueAsString(
+                ApiResponse.success(Arrays.asList("449856319", "715357119")));
+        Map<String, Object> response = objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {
+        });
+
+        assertEquals(3, response.size());
+        assertEquals(200, response.get("code"));
+        assertEquals(true, response.get("isSuccess"));
+        assertEquals(Arrays.asList("449856319", "715357119"), response.get("result"));
+    }
+}

+ 28 - 0
src/test/java/com/malk/minglei/dto/DepartmentUserIdsResponseTest.java

@@ -0,0 +1,28 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class DepartmentUserIdsResponseTest {
+
+    @Test
+    void serializesToTheSpecifiedResponseStructure() throws Exception {
+        ObjectMapper objectMapper = new ObjectMapper();
+
+        String json = objectMapper.writeValueAsString(
+                DepartmentUserIdsResponse.success(Arrays.asList("449856319", "715357119")));
+        Map<String, Object> response = objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {
+        });
+
+        assertEquals(3, response.size());
+        assertEquals(200, response.get("code"));
+        assertEquals(true, response.get("isSuccess"));
+        assertEquals(Arrays.asList("449856319", "715357119"), response.get("result"));
+    }
+}

+ 480 - 0
src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterLiveTest.java

@@ -0,0 +1,480 @@
+package com.malk.minglei.service.impl.employee;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.service.employee.DingTalkEmployeeRosterClient;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
+@EnabledIfSystemProperty(named = "employeeRosterLiveTest", matches = "true")
+class EmployeeRosterLiveTest {
+
+    private static final String TEST_USER_ID = "670709066";
+    private static final long DEFAULT_TEST_DEPARTMENT_ID = 962219824L;
+    private static final int DEFAULT_PAGE_SIZE = 100;
+    private static final int DEFAULT_ROSTER_BATCH_SIZE = 20;
+
+    @Autowired
+    private DDClient dingTalkClient;
+
+    @Autowired
+    private DDClient_Contacts contactsClient;
+
+    @Autowired
+    private DDConf dingTalkConfiguration;
+
+    @Autowired
+    private DingTalkEmployeeRosterClient rosterClient;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @Test
+    void printSingleEmployeeRoster() throws Exception {
+        String accessToken = dingTalkClient.getAccessToken();
+        assertNotNull(accessToken, "DingTalk access token must not be null");
+        assertNotNull(dingTalkConfiguration.getAgentId(), "DingTalk agentId must not be null");
+        assertTrue(dingTalkConfiguration.getAgentId().longValue() > 0, "DingTalk agentId must be greater than 0");
+
+        List<Map> fieldGroups = rosterClient.listRosterFieldGroups(accessToken, dingTalkConfiguration.getAgentId());
+        List<String> fieldCodes = resolveFieldCodes(fieldGroups);
+        String userId = System.getProperty("employeeRosterUserId", TEST_USER_ID);
+        List<Map> employeeRoster = rosterClient.listEmployeeRoster(
+                accessToken,
+                Collections.singletonList(userId),
+                dingTalkConfiguration.getAgentId(),
+                fieldCodes);
+
+        writeLiveResult(userId, fieldGroups, employeeRoster);
+
+        System.out.println("DingTalk roster field count: " + fieldCodes.size());
+        System.out.println("DingTalk employee roster response:");
+        System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(employeeRoster));
+
+        assertFalse(employeeRoster.isEmpty(), "DingTalk employee roster response must not be empty");
+    }
+
+    @Test
+    @EnabledIfSystemProperty(named = "employeeRosterDepartmentLiveTest", matches = "true")
+    void printDepartmentEmployeeRoster() throws Exception {
+        String accessToken = dingTalkClient.getAccessToken();
+        assertNotNull(accessToken, "DingTalk access token must not be null");
+        assertNotNull(dingTalkConfiguration.getAgentId(), "DingTalk agentId must not be null");
+        assertTrue(dingTalkConfiguration.getAgentId().longValue() > 0, "DingTalk agentId must be greater than 0");
+
+        long departmentId = Long.parseLong(System.getProperty(
+                "employeeRosterDepartmentId",
+                String.valueOf(DEFAULT_TEST_DEPARTMENT_ID)));
+        boolean includeSubDepartments = Boolean.parseBoolean(System.getProperty(
+                "employeeRosterIncludeSubDepartments",
+                "false"));
+
+        List<Map> fieldGroups = rosterClient.listRosterFieldGroups(accessToken, dingTalkConfiguration.getAgentId());
+        List<Map> users = listDepartmentUsers(accessToken, departmentId, includeSubDepartments);
+        List<String> targetFieldCodes = Arrays.asList(
+                "001bc7f2-750d-4820-b9f6-6680acfffcb6",
+                "ad410990-cee4-4e19-b70f-189a8ab87527");
+        List<Map> employeeRoster = fetchRoster(accessToken, extractUserIds(users), targetFieldCodes);
+
+        writeDepartmentLiveResult(departmentId, users, employeeRoster, targetFieldCodes);
+
+        System.out.println("DingTalk departmentId: " + departmentId);
+        System.out.println("DingTalk department user count: " + users.size());
+        System.out.println("DingTalk department roster response count: " + employeeRoster.size());
+        System.out.println(createTargetFieldSummary(employeeRoster, targetFieldCodes));
+
+        assertFalse(users.isEmpty(), "DingTalk department users must not be empty");
+    }
+
+    private List<String> extractFieldCodes(List<Map> fieldGroups) {
+        List<String> fieldCodes = new ArrayList<String>();
+        collectFieldCodes(fieldGroups, fieldCodes);
+        return fieldCodes;
+    }
+
+    private List<String> resolveFieldCodes(List<Map> fieldGroups) {
+        String configuredFieldCodes = System.getProperty("employeeRosterFieldCodes");
+        if (configuredFieldCodes == null || configuredFieldCodes.trim().isEmpty()) {
+            return extractFieldCodes(fieldGroups);
+        }
+
+        List<String> fieldCodes = new ArrayList<String>();
+        for (String fieldCode : Arrays.asList(configuredFieldCodes.split(","))) {
+            addFieldCode(fieldCodes, fieldCode);
+        }
+        return fieldCodes;
+    }
+
+    private void writeLiveResult(String userId, List<Map> fieldGroups, List<Map> employeeRoster) throws Exception {
+        Path outputDirectory = Paths.get("target", "employee-roster-live");
+        Files.createDirectories(outputDirectory);
+
+        Files.write(
+                outputDirectory.resolve("field-groups.json"),
+                objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(fieldGroups).getBytes(StandardCharsets.UTF_8));
+        Files.write(
+                outputDirectory.resolve("employee-" + userId + "-roster.json"),
+                objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(employeeRoster).getBytes(StandardCharsets.UTF_8));
+        Files.write(
+                outputDirectory.resolve("field-comparison.txt"),
+                createFieldComparison(fieldGroups, employeeRoster).getBytes(StandardCharsets.UTF_8));
+    }
+
+    private void writeDepartmentLiveResult(long departmentId,
+                                           List<Map> users,
+                                           List<Map> employeeRoster,
+                                           List<String> targetFieldCodes) throws Exception {
+        Path outputDirectory = Paths.get("target", "employee-roster-live");
+        Files.createDirectories(outputDirectory);
+
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("departmentId", departmentId);
+        result.put("users", users);
+        result.put("employeeRoster", employeeRoster);
+
+        Files.write(
+                outputDirectory.resolve("department-" + departmentId + "-roster.json"),
+                objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(result).getBytes(StandardCharsets.UTF_8));
+        Files.write(
+                outputDirectory.resolve("department-" + departmentId + "-target-field-summary.txt"),
+                createTargetFieldSummary(employeeRoster, targetFieldCodes).getBytes(StandardCharsets.UTF_8));
+    }
+
+    private String createFieldComparison(List<Map> fieldGroups, List<Map> employeeRoster) {
+        Map<String, String> allFields = new LinkedHashMap<String, String>();
+        collectFieldNames(fieldGroups, allFields);
+
+        Map<String, String> returnedFields = new LinkedHashMap<String, String>();
+        collectFieldNames(employeeRoster, returnedFields);
+
+        StringBuilder builder = new StringBuilder();
+        builder.append("allFieldCount=").append(allFields.size()).append(System.lineSeparator());
+        builder.append("returnedFieldCount=").append(returnedFields.size()).append(System.lineSeparator());
+        builder.append(System.lineSeparator()).append("returnedFields:").append(System.lineSeparator());
+        appendFields(builder, returnedFields);
+        builder.append(System.lineSeparator()).append("missingFields:").append(System.lineSeparator());
+        for (Map.Entry<String, String> entry : allFields.entrySet()) {
+            if (!returnedFields.containsKey(entry.getKey())) {
+                builder.append(entry.getKey()).append(" = ").append(entry.getValue()).append(System.lineSeparator());
+            }
+        }
+        return builder.toString();
+    }
+
+    private String createTargetFieldSummary(List<Map> employeeRoster, List<String> targetFieldCodes) {
+        Map<String, String> targetFieldNames = new LinkedHashMap<String, String>();
+        targetFieldNames.put("001bc7f2-750d-4820-b9f6-6680acfffcb6", "职称");
+        targetFieldNames.put("ad410990-cee4-4e19-b70f-189a8ab87527", "职等");
+
+        Map<String, Integer> hitCounts = new LinkedHashMap<String, Integer>();
+        for (String fieldCode : targetFieldCodes) {
+            hitCounts.put(fieldCode, Integer.valueOf(0));
+        }
+
+        StringBuilder builder = new StringBuilder();
+        builder.append("employeeRosterCount=").append(employeeRoster.size()).append(System.lineSeparator());
+        builder.append(System.lineSeparator()).append("hitEmployees:").append(System.lineSeparator());
+
+        for (Map roster : employeeRoster) {
+            Map<String, String> values = new LinkedHashMap<String, String>();
+            collectRosterFieldValues(roster, values);
+
+            String userId = firstText(roster, "userid", "userId", "user_id");
+            boolean hasTargetField = false;
+            StringBuilder employeeLine = new StringBuilder();
+            employeeLine.append(userId == null ? "<unknown>" : userId);
+            for (String fieldCode : targetFieldCodes) {
+                String value = values.get(fieldCode);
+                if (value != null) {
+                    hasTargetField = true;
+                    hitCounts.put(fieldCode, Integer.valueOf(hitCounts.get(fieldCode).intValue() + 1));
+                    employeeLine.append(" | ")
+                            .append(targetFieldNames.get(fieldCode))
+                            .append("=")
+                            .append(value);
+                }
+            }
+            if (hasTargetField) {
+                builder.append(employeeLine).append(System.lineSeparator());
+            }
+        }
+
+        builder.append(System.lineSeparator()).append("hitCounts:").append(System.lineSeparator());
+        for (String fieldCode : targetFieldCodes) {
+            builder.append(fieldCode)
+                    .append(" = ")
+                    .append(targetFieldNames.get(fieldCode))
+                    .append(", count=")
+                    .append(hitCounts.get(fieldCode))
+                    .append(System.lineSeparator());
+        }
+        return builder.toString();
+    }
+
+    private List<Map> listDepartmentUsers(String accessToken, long departmentId, boolean includeSubDepartments) {
+        Set<Long> departmentIds = new LinkedHashSet<Long>();
+        departmentIds.add(Long.valueOf(departmentId));
+        if (includeSubDepartments) {
+            List<Long> childDepartmentIds = contactsClient.getDepartmentId_all(accessToken, false, departmentId);
+            if (childDepartmentIds != null) {
+                departmentIds.addAll(childDepartmentIds);
+            }
+        }
+
+        Map<String, Map> usersByUserId = new LinkedHashMap<String, Map>();
+        for (Long currentDepartmentId : departmentIds) {
+            if (currentDepartmentId == null) {
+                continue;
+            }
+            appendDepartmentUsers(accessToken, currentDepartmentId.longValue(), usersByUserId);
+        }
+        return new ArrayList<Map>(usersByUserId.values());
+    }
+
+    private void appendDepartmentUsers(String accessToken, long departmentId, Map<String, Map> usersByUserId) {
+        long cursor = 0L;
+        while (true) {
+            Map<String, Object> options = new LinkedHashMap<String, Object>();
+            options.put("cursor", Long.valueOf(cursor));
+            options.put("size", Integer.valueOf(DEFAULT_PAGE_SIZE));
+            options.put("language", "zh_CN");
+
+            Map result = contactsClient.listDeptUserDetail_v2(accessToken, departmentId, options);
+            for (Map user : extractUserList(result)) {
+                String userId = firstText(user, "userid", "userId", "user_id");
+                if (userId != null && !usersByUserId.containsKey(userId)) {
+                    usersByUserId.put(userId, user);
+                }
+            }
+
+            if (!toBoolean(result == null ? null : result.get("has_more"))) {
+                break;
+            }
+            Long nextCursor = toLong(result.get("next_cursor"));
+            if (nextCursor == null || nextCursor.longValue() == cursor) {
+                break;
+            }
+            cursor = nextCursor.longValue();
+        }
+    }
+
+    private List<Map> fetchRoster(String accessToken, List<String> userIds, List<String> fieldCodes) {
+        List<Map> employeeRoster = new ArrayList<Map>();
+        for (int start = 0; start < userIds.size(); start += DEFAULT_ROSTER_BATCH_SIZE) {
+            int end = Math.min(start + DEFAULT_ROSTER_BATCH_SIZE, userIds.size());
+            employeeRoster.addAll(rosterClient.listEmployeeRoster(
+                    accessToken,
+                    userIds.subList(start, end),
+                    dingTalkConfiguration.getAgentId(),
+                    fieldCodes));
+        }
+        return employeeRoster;
+    }
+
+    private List<String> extractUserIds(List<Map> users) {
+        List<String> userIds = new ArrayList<String>();
+        for (Map user : users) {
+            String userId = firstText(user, "userid", "userId", "user_id");
+            if (userId != null) {
+                userIds.add(userId);
+            }
+        }
+        return userIds;
+    }
+
+    private List<Map> extractUserList(Map result) {
+        if (result == null) {
+            return Collections.emptyList();
+        }
+        Object list = firstNotNull(result.get("list"), result.get("userlist"), result.get("users"));
+        if (!(list instanceof Iterable)) {
+            return Collections.emptyList();
+        }
+        List<Map> users = new ArrayList<Map>();
+        for (Object item : (Iterable<?>) list) {
+            if (item instanceof Map) {
+                users.add((Map) item);
+            }
+        }
+        return users;
+    }
+
+    private void appendFields(StringBuilder builder, Map<String, String> fields) {
+        for (Map.Entry<String, String> entry : fields.entrySet()) {
+            builder.append(entry.getKey()).append(" = ").append(entry.getValue()).append(System.lineSeparator());
+        }
+    }
+
+    private void collectFieldNames(Object value, Map<String, String> fields) {
+        if (value instanceof Map) {
+            Map map = (Map) value;
+            String fieldCode = firstText(map, "field_code", "fieldCode", "code");
+            String fieldName = firstText(map, "field_name", "fieldName", "name", "label");
+            if (fieldCode != null && fieldName != null) {
+                fields.put(fieldCode, fieldName);
+            }
+            for (Object child : map.values()) {
+                collectFieldNames(child, fields);
+            }
+            return;
+        }
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                collectFieldNames(item, fields);
+            }
+        }
+    }
+
+    private void collectRosterFieldValues(Object value, Map<String, String> fields) {
+        if (value instanceof Map) {
+            Map map = (Map) value;
+            String fieldCode = firstText(map, "field_code", "fieldCode", "code");
+            if (fieldCode != null) {
+                String fieldValue = firstText(map, "field_value", "fieldValue", "value", "text", "label_value", "labelValue");
+                if (fieldValue == null) {
+                    fieldValue = toDisplayText(firstNotNull(map.get("field_value_list"), map.get("fieldValueList"), map.get("values")));
+                }
+                if (fieldValue != null) {
+                    fields.put(fieldCode, fieldValue);
+                }
+            }
+            for (Object child : map.values()) {
+                collectRosterFieldValues(child, fields);
+            }
+            return;
+        }
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                collectRosterFieldValues(item, fields);
+            }
+        }
+    }
+
+    private void collectFieldCodes(Object value, List<String> fieldCodes) {
+        if (value instanceof Map) {
+            Map map = (Map) value;
+            addFieldCode(fieldCodes, map.get("field_code"));
+            addFieldCode(fieldCodes, map.get("fieldCode"));
+            for (Object child : map.values()) {
+                collectFieldCodes(child, fieldCodes);
+            }
+            return;
+        }
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                collectFieldCodes(item, fieldCodes);
+            }
+        }
+    }
+
+    private void addFieldCode(List<String> fieldCodes, Object value) {
+        if (value == null) {
+            return;
+        }
+        String fieldCode = String.valueOf(value).trim();
+        if (!fieldCode.isEmpty() && !fieldCodes.contains(fieldCode)) {
+            fieldCodes.add(fieldCode);
+        }
+    }
+
+    private String firstText(Map map, String firstKey, String... otherKeys) {
+        String value = toStringValue(map.get(firstKey));
+        if (value != null) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = toStringValue(map.get(key));
+            if (value != null) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Object firstNotNull(Object first, Object second) {
+        return first == null ? second : first;
+    }
+
+    private Object firstNotNull(Object first, Object second, Object third) {
+        if (first != null) {
+            return first;
+        }
+        return second == null ? third : second;
+    }
+
+    private Boolean toBoolean(Object value) {
+        if (value instanceof Boolean) {
+            return (Boolean) value;
+        }
+        if (value instanceof Number) {
+            return Boolean.valueOf(((Number) value).intValue() != 0);
+        }
+        if (value != null) {
+            return Boolean.valueOf(String.valueOf(value));
+        }
+        return Boolean.FALSE;
+    }
+
+    private Long toLong(Object value) {
+        if (value instanceof Number) {
+            return Long.valueOf(((Number) value).longValue());
+        }
+        String text = toStringValue(value);
+        if (text == null) {
+            return null;
+        }
+        return Long.valueOf(text);
+    }
+
+    private String toStringValue(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Iterable) {
+            return toDisplayText(value);
+        }
+        String text = String.valueOf(value).trim();
+        return text.isEmpty() ? null : text;
+    }
+
+    private String toDisplayText(Object value) {
+        if (value instanceof Iterable) {
+            List<String> texts = new ArrayList<String>();
+            for (Object item : (Iterable<?>) value) {
+                String text = toDisplayText(item);
+                if (text != null) {
+                    texts.add(text);
+                }
+            }
+            return texts.isEmpty() ? null : String.join(",", texts);
+        }
+        if (value instanceof Map) {
+            Map map = (Map) value;
+            String text = firstText(map, "value", "text", "label", "name");
+            return text == null ? null : text;
+        }
+        return toStringValue(value);
+    }
+}

+ 208 - 0
src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java

@@ -0,0 +1,208 @@
+package com.malk.minglei.service.impl.employee;
+
+import com.malk.minglei.config.EmployeeRosterProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.dto.EmployeeRosterSyncResult;
+import com.malk.minglei.service.employee.DingTalkEmployeeRosterClient;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+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.assertSame;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class EmployeeRosterServiceImplTest {
+
+    @Test
+    void findByEmployeeNoDelegatesToRepository() {
+        EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setDingtalkId("dingtalk-user-001");
+        given(repository.findByEmployeeNo("1001")).willReturn(employee);
+
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
+                null,
+                null,
+                null,
+                null,
+                repository,
+                new EmployeeRosterProperties());
+
+        assertSame(employee, service.findByEmployeeNo(" 1001 "));
+        verify(repository).findByEmployeeNo("1001");
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void syncCollectsDepartmentUsersAndUpsertsRosterRecords() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        DingTalkEmployeeRosterClient rosterClient = mock(DingTalkEmployeeRosterClient.class);
+        EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        DDConf dingTalkConfiguration = new DDConf();
+        dingTalkConfiguration.setAgentId(123456L);
+
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(contactsClient.getDepartmentId_all("access-token", false, 1L)).willReturn(Collections.singletonList(2L));
+        given(contactsClient.listDeptUserDetail_v2(eq("access-token"), eq(1L), anyMap())).willReturn(mapOf(
+                "list", Arrays.asList(user("user-1", "Alice", "1001"), user("user-2", "Bob", "1002")),
+                "has_more", false));
+        given(contactsClient.listDeptUserDetail_v2(eq("access-token"), eq(2L), anyMap())).willReturn(mapOf(
+                "list", Arrays.asList(user("user-2", "Bob", "1002"), user("user-3", "Carol", "1003")),
+                "has_more", false));
+        given(rosterClient.listRosterFieldGroups("access-token", 123456L)).willReturn(Collections.singletonList(mapOf(
+                "field_list", Arrays.asList(
+                        field("sys00-jobTitle", "职称"),
+                        field("sys00-jobGrade", "职等"),
+                        field("sys00-department", "部门"),
+                        field("sys00-firstDepartment", "一级部门")))));
+        given(rosterClient.listEmployeeRoster(eq("access-token"), anyList(), eq(123456L), eq(Arrays.asList(
+                "sys00-jobTitle", "sys00-jobGrade", "sys00-department", "sys00-firstDepartment"))))
+                .willReturn(Arrays.asList(
+                        roster("user-1", "高级工程师", "P6", "技术部", "研发中心"),
+                        roster("user-2", "工程师", "P5", "技术部", "研发中心"),
+                        roster("user-3", "专员", "P4", "行政部", "职能中心")));
+        given(repository.upsertAll(anyList())).willReturn(3);
+
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
+                dingTalkClient,
+                contactsClient,
+                dingTalkConfiguration,
+                rosterClient,
+                repository,
+                new EmployeeRosterProperties());
+
+        EmployeeRosterSyncResult result = service.sync(1L, 0);
+
+        assertEquals(1L, result.getDepartmentId());
+        assertEquals(true, result.isIncludeSubDepartments());
+        assertEquals(3, result.getUserCount());
+        assertEquals(4, result.getFieldCount());
+        assertEquals(3, result.getSyncedCount());
+
+        ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
+        verify(repository).upsertAll(captor.capture());
+        List<EmployeeRosterRecord> records = captor.getValue();
+        assertEquals("user-1", records.get(0).getDingtalkId());
+        assertEquals("Alice", records.get(0).getUsername());
+        assertEquals("1001", records.get(0).getEmployeeNo());
+        assertEquals("高级工程师", records.get(0).getJobTitle());
+        assertEquals("10040", records.get(0).getJobTitleId());
+        assertEquals("P6", records.get(0).getJobGrade());
+        assertEquals("10010", records.get(0).getJobGradeId());
+        assertEquals("技术部", records.get(0).getDepartment());
+        assertEquals("研发中心", records.get(0).getFirstDepartment());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void syncKeepsJobTitleAndJobGradeNullWhenRosterFieldsAreMissing() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        DingTalkEmployeeRosterClient rosterClient = mock(DingTalkEmployeeRosterClient.class);
+        EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        DDConf dingTalkConfiguration = new DDConf();
+        dingTalkConfiguration.setAgentId(123456L);
+
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(contactsClient.listDeptUserDetail_v2(eq("access-token"), eq(1L), anyMap())).willReturn(mapOf(
+                "list", Collections.singletonList(userWithTitle("user-1", "Alice", "1001", "Fallback Title")),
+                "has_more", false));
+        given(rosterClient.listRosterFieldGroups("access-token", 123456L)).willReturn(Collections.singletonList(mapOf(
+                "field_list", Arrays.asList(
+                        field("001bc7f2-750d-4820-b9f6-6680acfffcb6", "职称"),
+                        field("ad410990-cee4-4e19-b70f-189a8ab87527", "职等"),
+                        field("sys00-position", "职位"),
+                        field("sys01-positionLevel", "岗位职级")))));
+        given(rosterClient.listEmployeeRoster(eq("access-token"), anyList(), eq(123456L), anyList()))
+                .willReturn(Collections.singletonList(mapOf("userid", "user-1", "field_data_list", Arrays.asList(
+                        rosterField("sys00-position", "Fallback Position"),
+                        rosterField("sys01-positionLevel", "Fallback Level")))));
+
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
+                dingTalkClient,
+                contactsClient,
+                dingTalkConfiguration,
+                rosterClient,
+                repository,
+                new EmployeeRosterProperties());
+
+        service.sync(1L, 1);
+
+        ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
+        verify(repository).upsertAll(captor.capture());
+        List<EmployeeRosterRecord> records = captor.getValue();
+        assertNull(records.get(0).getJobTitle());
+        assertNull(records.get(0).getJobTitleId());
+        assertNull(records.get(0).getJobGrade());
+        assertNull(records.get(0).getJobGradeId());
+    }
+
+    private static Map<String, Object> user(String userId, String name, String jobNumber) {
+        Map<String, Object> user = new LinkedHashMap<String, Object>();
+        user.put("userid", userId);
+        user.put("name", name);
+        user.put("job_number", jobNumber);
+        user.put("dept_id_list", Collections.singletonList(1L));
+        return user;
+    }
+
+    private static Map<String, Object> userWithTitle(String userId, String name, String jobNumber, String title) {
+        Map<String, Object> user = user(userId, name, jobNumber);
+        user.put("title", title);
+        return user;
+    }
+
+    private static Map<String, Object> mapOf(String key, Object value) {
+        Map<String, Object> map = new LinkedHashMap<String, Object>();
+        map.put(key, value);
+        return map;
+    }
+
+    private static Map<String, Object> mapOf(String key1, Object value1, String key2, Object value2) {
+        Map<String, Object> map = mapOf(key1, value1);
+        map.put(key2, value2);
+        return map;
+    }
+
+    private static Map<String, Object> field(String fieldCode, String fieldName) {
+        Map<String, Object> field = new LinkedHashMap<String, Object>();
+        field.put("field_code", fieldCode);
+        field.put("field_name", fieldName);
+        return field;
+    }
+
+    private static Map<String, Object> roster(String userId, String jobTitle, String jobGrade, String department, String firstDepartment) {
+        return mapOf("userid", userId, "field_data_list", Arrays.asList(
+                rosterField("sys00-jobTitle", jobTitle, "10040"),
+                rosterField("sys00-jobGrade", jobGrade, "10010"),
+                rosterField("sys00-department", department),
+                rosterField("sys00-firstDepartment", firstDepartment)));
+    }
+
+    private static Map<String, Object> rosterField(String fieldCode, String value) {
+        return rosterField(fieldCode, value, value);
+    }
+
+    private static Map<String, Object> rosterField(String fieldCode, String label, String value) {
+        Map<String, Object> field = new LinkedHashMap<String, Object>();
+        field.put("field_code", fieldCode);
+        field.put("field_value_list", Collections.singletonList(mapOf("label", label, "value", value)));
+        return field;
+    }
+}

+ 74 - 0
src/test/java/com/malk/minglei/service/impl/port/MingleiPortServiceImplTest.java

@@ -0,0 +1,74 @@
+package com.malk.minglei.service.impl.port;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.MingleiPortProperties;
+import com.malk.minglei.dto.MingleiPortRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+class MingleiPortServiceImplTest {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+    private MockRestServiceServer server;
+    private MingleiPortServiceImpl mingleiPortService;
+
+    @BeforeEach
+    void setUp() {
+        RestTemplate restTemplate = new RestTemplate();
+        server = MockRestServiceServer.createServer(restTemplate);
+
+        MingleiPortProperties properties = new MingleiPortProperties();
+        properties.setSupplierPriceApprovalUrl("http://external.example/wtoptst/ws/r/awsp920");
+        properties.setSupplierPriceApprovalKey("key-1");
+        mingleiPortService = new MingleiPortServiceImpl(restTemplate, properties);
+    }
+
+    @Test
+    void submitSupplierPriceApprovalForwardsDocumentAndReturnsExternalResponse() throws Exception {
+        server.expect(requestTo("http://external.example/wtoptst/ws/r/awsp920"))
+                .andExpect(method(org.springframework.http.HttpMethod.POST))
+                .andExpect(header("Content-Type", MediaType.APPLICATION_JSON_VALUE))
+                .andExpect(content().json(externalRequestJson()))
+                .andRespond(withSuccess("{\"srvver\":\"1.0\",\"srvcode\":\"100\",\"datakey\":null,\"payload\":{\"std_data\":{\"execution\":{\"code\":\"0\",\"sql_code\":null,\"description\":\"SUCCESS\"},\"parameter\":{\"pmdidocno\":\"CH3\",\"tran_time\":\"2026-08-25 10:00:00\"}}}}", MediaType.APPLICATION_JSON));
+
+        Map<String, Object> response = mingleiPortService.submitSupplierPriceApproval(readRequest());
+
+        assertEquals("100", response.get("srvcode"));
+        server.verify();
+    }
+
+    private MingleiPortRequest readRequest() throws Exception {
+        return objectMapper.readValue(localRequestJson(), MingleiPortRequest.class);
+    }
+
+    private String localRequestJson() {
+        return "{"
+                + "\"head\":{\"pmdidocno\":\"CH3\",\"pmdi001\":\"N\",\"pmdi034\":\"605188\",\"pmdidocdt\":\"2026-08-06\",\"pmdi004\":\"517022\",\"pmdi015\":\"2026-08-06\",\"pmdi016\":\"2027-06-01\",\"pmdi003\":\"A043\",\"pmdi002\":\"210721017\",\"pmdiud001\":\"N\",\"pmdistus\":\"I\"},"
+                + "\"detail\":[{\"pmdj011\":\"0.5000\",\"pmdj002\":\"524000059517022000000\",\"pmdj030\":\"\"}]"
+                + "}";
+    }
+
+    private String externalRequestJson() {
+        return "{"
+                + "\"key\":\"key-1\",\"type\":\"sync\","
+                + "\"host\":{\"prod\":\"APP\",\"ip\":\"10.0.2.60\",\"lang\":\"zh_CN\",\"acct\":\"tiptop\",\"timestamp\":\"20260807164700004\"},"
+                + "\"service\":{\"prod\":\"T100\",\"name\":\"ProcurementApprovalPrice\",\"ip\":\"10.0.2.60\",\"id\":\"toptst\"},"
+                + "\"datakey\":{\"EntId\":\"87\",\"CompanyId\":\"ML\"},"
+                + "\"payload\":{\"std_data\":{\"parameter\":{"
+                + "\"head\":{\"pmdidocno\":\"CH3\",\"pmdi001\":\"N\",\"pmdi034\":\"605188\",\"pmdidocdt\":\"2026-08-06\",\"pmdi004\":\"517022\",\"pmdi015\":\"2026-08-06\",\"pmdi016\":\"2027-06-01\",\"pmdi003\":\"A043\",\"pmdi002\":\"210721017\",\"pmdiud001\":\"N\",\"pmdistus\":\"I\"},"
+                + "\"detail\":[{\"pmdj011\":\"0.5000\",\"pmdj002\":\"524000059517022000000\",\"pmdj030\":\"\"}]"
+                + "}}}}";
+    }
+}

+ 165 - 0
src/test/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandlerTest.java

@@ -0,0 +1,165 @@
+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.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ApproveOrderFormPushHandlerTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushMapsCustomerApproveOrderPayloadToYidaFieldIds() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-1");
+        when(yidaClient.startProcess(org.mockito.ArgumentMatchers.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.setExtraField("xmdaent", "87");
+        mainData.setExtraField("xmdadocno", "SO-001");
+        mainData.setExtraField("xmdadocdt", "2026-09-15");
+        mainData.setExtraField("xmda001", "2");
+        mainData.setExtraField("xmda002", "sales-user-001");
+        mainData.setExtraField("xmda003", "dept-001");
+        mainData.setExtraField("xmda004", "CUS-001");
+        mainData.setExtraField("pmaal004", "customer-short-name");
+        mainData.setExtraField("xmda005", "normal-order");
+        mainData.setExtraField("xmda013", "Y");
+        mainData.setExtraField("xmda042", "1234.56");
+        mainData.setExtraField("xmda071", "remark-001");
+
+        Map<String, Object> deliverySourceRow = new LinkedHashMap<String, Object>();
+        deliverySourceRow.put("xmdcent", "87");
+        deliverySourceRow.put("xmdcdocno", "SO-001");
+        deliverySourceRow.put("xmdcseq", "10");
+        deliverySourceRow.put("xmdc001", "ITEM-001");
+        deliverySourceRow.put("xmdc002", "FEATURE-001");
+        deliverySourceRow.put("imaal003", "item-name");
+        deliverySourceRow.put("imaal004", "spec-001");
+        deliverySourceRow.put("xmdc006", "PCS");
+        deliverySourceRow.put("xmdc007", "100");
+        deliverySourceRow.put("xmdc012", "2026-09-30");
+        deliverySourceRow.put("xmdc015", "12.3");
+        deliverySourceRow.put("xmdc019", "child-feature");
+        deliverySourceRow.put("xmdc046", "1000");
+        deliverySourceRow.put("xmdc047", "1130");
+        deliverySourceRow.put("xmdc050", "normal-delivery");
+        deliverySourceRow.put("xmdcua003", "2026-09-20");
+
+        Map<String, Object> orderSourceRow = new LinkedHashMap<String, Object>();
+        orderSourceRow.put("xmddent", "87");
+        orderSourceRow.put("xmdddocno", "SO-001");
+        orderSourceRow.put("xmddseq", "10");
+        orderSourceRow.put("xmddseq1", "11");
+        orderSourceRow.put("xmddseq2", "12");
+        orderSourceRow.put("xmdd001", "ITEM-002");
+        orderSourceRow.put("xmdd002", "FEATURE-002");
+        orderSourceRow.put("xmdd004", "BOX");
+        orderSourceRow.put("xmdd005", "200");
+        orderSourceRow.put("xmdd006", "80");
+        orderSourceRow.put("xmdd009", "delivery-type-2");
+        orderSourceRow.put("xmdd011", "2026-10-01");
+        orderSourceRow.put("xmdd014", "20");
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(deliverySourceRow));
+        detailData.put("detailData2", Collections.singletonList(orderSourceRow));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(ApproveOrderFormPushHandler.TYPE);
+        request.setBm("dept-001");
+        request.setGs("ML");
+        request.setTjr("user-001");
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+
+        ApproveOrderFormPushHandler handler = new ApproveOrderFormPushHandler(yidaClient, ddConf, applications);
+
+        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("system-token", body.get("systemToken"));
+        assertEquals("41603502201288023", body.get("userId"));
+        assertEquals("TPROC--9VD66QA18AJ8317KK3NXM54W1HIT30C7N28TM7", body.get("processCode"));
+        assertEquals("FORM-8454FA17732746058A2D90B1C559CC19G5SR", body.get("formUuid"));
+        assertEquals("approveOrder-SO-001", body.get("title"));
+        assertEquals("approveOrder-SO-001", body.get("dataTitle"));
+
+        JSONObject formData = JSONObject.parseObject(String.valueOf(body.get("formDataJson")));
+        assertEquals("approveOrder-SO-001", formData.getString("textField_msh27unu"));
+        assertEquals("approveOrder-SO-001", formData.getString("textField_mspcpz92"));
+        assertEquals("41603502201288023", formData.getJSONArray("employeeField_msh186rr").getString(0));
+        assertEquals("ML", formData.getString("selectField_mtv87n9f"));
+        assertEquals("dept-001", formData.getJSONArray("departmentSelectField_msh186ry").getString(0));
+        assertEquals(Long.valueOf(1789401600000L), formData.getLong("dateField_msh186rs"));
+        assertEquals("正常", formData.getString("textField_mt7zi4fy"));
+        assertEquals("87", formData.getString("textField_mt82p09i"));
+        assertEquals("SO-001", formData.getString("textField_mt82p09d"));
+        assertEquals(Long.valueOf(1789401600000L), formData.getLong("dateField_mt82p09f"));
+        assertEquals("2", formData.getString("numberField_mt82p090"));
+        assertEquals("sales-user-001", formData.getJSONArray("employeeField_mt82p08t").getString(0));
+        assertEquals("dept-001", formData.getJSONArray("departmentSelectField_mt82p08u").getString(0));
+        assertEquals("CUS-001", formData.getString("textField_mt80nq6v"));
+        assertEquals("customer-short-name", formData.getString("textField_mt82p08x"));
+        assertEquals("normal-order", formData.getString("textField_mt82p091"));
+        assertEquals("Y", formData.getString("textField_mt82p092"));
+        assertEquals("1234.56", formData.getString("textField_mt82p094"));
+        assertEquals("remark-001", formData.getString("textField_mt82p097"));
+        assertEquals("否", formData.getString("selectField_mt82p09c"));
+        assertEquals("审核", formData.getString("selectField_mtgsgnvi"));
+        assertEquals("无", formData.getJSONArray("checkboxField_mtgsgnvj").getString(0));
+
+        JSONArray deliveryRows = formData.getJSONArray("tableField_mt83ctpv");
+        assertEquals(1, deliveryRows.size());
+        JSONObject deliveryRow = deliveryRows.getJSONObject(0);
+        assertEquals("100", deliveryRow.getString("textField_mt82p0al"));
+        assertEquals(Long.valueOf(1790697600000L), deliveryRow.getLong("dateField_mt82p09r"));
+        assertEquals("12.3", deliveryRow.getString("textField_mt82p09t"));
+        assertEquals("child-feature", deliveryRow.getString("textField_mt82p0an"));
+        assertEquals("1000", deliveryRow.getString("textField_mt82p0ao"));
+        assertEquals("1130", deliveryRow.getString("textField_mt82p0ap"));
+        assertEquals(Long.valueOf(1789833600000L), deliveryRow.getLong("dateField_mt82p09w"));
+
+        JSONArray orderRows = formData.getJSONArray("tableField_8yaa9o8qk");
+        assertEquals(1, orderRows.size());
+        JSONObject orderRow = orderRows.getJSONObject(0);
+        assertEquals("87", orderRow.getString("textField_8yaaaieif"));
+        assertEquals("SO-001", orderRow.getString("textField_8yabb4o9b"));
+        assertEquals("10", orderRow.getString("numberField_mt82p0af"));
+        assertEquals("11", orderRow.getString("numberField_mt82p0ah"));
+        assertEquals("12", orderRow.getString("numberField_mt82p0ai"));
+        assertEquals("ITEM-002", orderRow.getString("textField_8yabff0mk"));
+        assertEquals("FEATURE-002", orderRow.getString("textField_8yabis8xh"));
+        assertEquals("BOX", orderRow.getString("textField_8yabjecf7"));
+        assertEquals("delivery-type-2", orderRow.getString("textField_mt82p0aj"));
+        assertEquals("200", orderRow.getString("textField_8yabl1tlp"));
+        assertEquals(Long.valueOf(1790784000000L), orderRow.getLong("dateField_8yabm8rmr"));
+    }
+}

+ 106 - 0
src/test/java/com/malk/minglei/service/impl/yida/Cpmp402YidaProcessHandlerTest.java

@@ -0,0 +1,106 @@
+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.YidaOperationParam;
+import com.malk.minglei.dto.YidaProcessStartRequest;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.aliwork.YDClient;
+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.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class Cpmp402YidaProcessHandlerTest {
+
+    @Test
+    void startProcessMapsCpmp402PayloadToYidaFieldIds() {
+        YDClient yidaClient = mock(YDClient.class);
+        when(yidaClient.operateData(org.mockito.ArgumentMatchers.any(YidaOperationParam.class),
+                eq(YDConf.FORM_OPERATION.start))).thenReturn("process-instance-1");
+
+        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");
+
+        Map<String, Object> mainData = new LinkedHashMap<String, Object>();
+        mainData.put("rq", "1799337600000");
+        mainData.put("bm", "ML07000000");
+        mainData.put("gs", "ML");
+        mainData.put("t100id", "ML");
+        mainData.put("ent", "87");
+        mainData.put("t100bm", "ML07000000");
+
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("hjdh", "MLCH326090800001");
+        detailRow.put("gysbh", "113009");
+        detailRow.put("gysmc", "supplier-name");
+        detailRow.put("t100id", "ML");
+        detailRow.put("ent", "87");
+        detailRow.put("yy", "zh_CN");
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData", Collections.singletonList(detailRow));
+
+        Map<String, Object> fields = new LinkedHashMap<String, Object>();
+        fields.put("mainData", mainData);
+        fields.put("detailData", detailData);
+
+        YidaProcessStartRequest request = new YidaProcessStartRequest();
+        request.setType("cpmp402");
+        request.setUserId("41603502201288023");
+        request.setDataTitle("package-price-title");
+        request.setFields(fields);
+
+        Cpmp402YidaProcessHandler handler = new Cpmp402YidaProcessHandler(yidaClient, ddConf, applications);
+
+        assertEquals("process-instance-1", handler.startProcess(request));
+
+        ArgumentCaptor<YidaOperationParam> captor = ArgumentCaptor.forClass(YidaOperationParam.class);
+        verify(yidaClient).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.start));
+        YidaOperationParam parameter = captor.getValue();
+        assertEquals("APP_RJJL69QUIZVQSV7YC8TL", parameter.getAppType());
+        assertEquals("system-token", parameter.getSystemToken());
+        assertEquals("41603502201288023", parameter.getUserId());
+        assertEquals("FORM-A3D4889187E54697A59590BE54F350E7AOO5", parameter.getFormUuid());
+        assertEquals("TPROC--98G66QB1NTF8E1R1LI0OK4D6JI9437HC92YSM1", parameter.getProcessCode());
+        assertEquals(true, parameter.isExecuteExpression());
+        assertEquals(false, parameter.isNoExecuteExpression());
+        assertEquals("package-price-title", parameter.getTitle());
+        assertEquals("package-price-title", parameter.getDataTitle());
+
+        JSONObject formData = JSONObject.parseObject(parameter.getFormDataJson());
+        assertEquals("package-price-title", formData.getString("textField_msh27unu"));
+        assertEquals("package-price-title", formData.getString("textField_mspcpz92"));
+        assertEquals("41603502201288023", formData.getJSONArray("employeeField_msh186rr").getString(0));
+        assertEquals("ML", formData.getString("selectField_mso1em48"));
+        assertEquals(Long.valueOf(1799337600000L), formData.getLong("dateField_msh186rs"));
+        assertEquals("明磊", formData.getString("selectField_msy2akpj"));
+        assertEquals("ML", formData.getString("textField_msy2akpi"));
+        assertEquals("ML07000000", formData.getString("textField_msy2akpk"));
+
+        JSONArray rows = formData.getJSONArray("tableField_msh186t3");
+        assertEquals(1, rows.size());
+        JSONObject row = rows.getJSONObject(0);
+        assertEquals("MLCH326090800001", row.getString("textField_msy2akp1"));
+        assertEquals("113009", row.getString("textField_msy2akp2"));
+        assertEquals("supplier-name", row.getString("textField_msy2akp3"));
+        assertEquals("ML", row.getString("textField_msy2akpp"));
+        assertEquals("明磊", row.getString("selectField_msy2akpq"));
+        assertEquals("zh_CN", row.getString("textField_msy2akpr"));
+    }
+}

+ 115 - 0
src/test/java/com/malk/minglei/service/impl/yida/CustomerReleaseFormPushHandlerTest.java

@@ -0,0 +1,115 @@
+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.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CustomerReleaseFormPushHandlerTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushMapsCustomerReleasePayloadToYidaFieldIds() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-1");
+        when(yidaClient.startProcess(org.mockito.ArgumentMatchers.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.setExtraField("xmfxent", "87");
+        mainData.setExtraField("xmfxdocno", "CR-001");
+        mainData.setExtraField("xmfxdocdt", "2026-09-15");
+        mainData.setExtraField("xmfx001", "applicant-001");
+        mainData.setExtraField("xmfx002", "dept-code-001");
+        mainData.setExtraField("xmfx003", "release-desc");
+        mainData.setExtraField("xmfxud001", "cannot-deliver-reason");
+
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("xmfyent", "87");
+        detailRow.put("xmfydocno", "CR-001");
+        detailRow.put("xmfyseq", "10");
+        detailRow.put("xmfy001", "DN");
+        detailRow.put("xmfy002", "DN-001");
+        detailRow.put("xmfy003", "2026-09-16");
+        detailRow.put("xmfy004", "CUS-001");
+        detailRow.put("pmaal004", "customer-name");
+        detailRow.put("xmfy005", "1000.5");
+        detailRow.put("xmfy006", "2000.5");
+        detailRow.put("xmfy007", "short-remark");
+        detailRow.put("xmfy008", "3000.5");
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(detailRow));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(CustomerReleaseFormPushHandler.TYPE);
+        request.setBm("dept-001");
+        request.setGs("ML");
+        request.setTjr("user-001");
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+
+        CustomerReleaseFormPushHandler handler = new CustomerReleaseFormPushHandler(yidaClient, ddConf, applications);
+
+        assertEquals(Collections.singletonList("axmt140"), 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("system-token", body.get("systemToken"));
+        assertEquals("41603502201288023", body.get("userId"));
+        assertEquals("TPROC--6TG66381N8G8Y7L9GBEEY7QVXTUY2LBBGZ7TMU", body.get("processCode"));
+        assertEquals("FORM-3BA9FE18494F4FDD85FE6ABF7DE741BAE30C", body.get("formUuid"));
+        assertEquals("customerRelease-CR-001", body.get("title"));
+        assertEquals("customerRelease-CR-001", body.get("dataTitle"));
+
+        JSONObject formData = JSONObject.parseObject(String.valueOf(body.get("formDataJson")));
+        assertEquals("customerRelease-CR-001", formData.getString("textField_mtv1tgg1"));
+        assertEquals("customerRelease-CR-001", formData.getString("textField_mspcpz92"));
+        assertEquals("41603502201288023", formData.getJSONArray("employeeField_msh186rr").getString(0));
+        assertEquals("ML", formData.getString("selectField_mtv7uxgd"));
+        assertEquals("dept-001", formData.getJSONArray("departmentSelectField_msh186ry").getString(0));
+        assertEquals(Long.valueOf(1789401600000L), formData.getLong("dateField_msh186rs"));
+        assertEquals("CR-001", formData.getString("textField_mt7zi4fy"));
+        assertEquals("release-desc", formData.getString("textField_mt7zi4g0"));
+        assertEquals("cannot-deliver-reason", formData.getString("textField_mt7zi4fz"));
+
+        JSONArray detailRows = formData.getJSONArray("tableField_msh186sv");
+        assertEquals(1, detailRows.size());
+        JSONObject row = detailRows.getJSONObject(0);
+        assertEquals("10", row.getString("textField_mt7zi4g1"));
+        assertEquals("DN-001", row.getString("textField_mt7zi4g2"));
+        assertEquals(Long.valueOf(1789488000000L), row.getLong("dateField_mt7zi4g3"));
+        assertEquals("CUS-001", row.getString("textField_mt6ur4w1"));
+        assertEquals("customer-name", row.getString("textField_mt7zi4g4"));
+        assertEquals("1000.5", row.getString("numberField_mt7zi4g5"));
+        assertEquals("2000.5", row.getString("numberField_mt7zi4g6"));
+        assertEquals("3000.5", row.getString("numberField_mt7zi4g7"));
+        assertEquals("short-remark", row.getString("textField_mt6ur4w8"));
+    }
+}

+ 245 - 0
src/test/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImplTest.java

@@ -0,0 +1,245 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.dto.ErpYidaPushMainData;
+import com.malk.minglei.dto.YidaProcessStartRequest;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import com.malk.minglei.service.yida.YidaFormPushHandler;
+import com.malk.minglei.service.yida.YidaProcessService;
+import com.malk.server.common.McR;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.slf4j.Logger;
+
+import java.util.Collections;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.times;
+
+class ErpYidaPushServiceImplTest {
+
+    @Test
+    void convertsErpPayloadAndDispatchesYidaProcess() {
+        Logger logger = mock(Logger.class);
+        YidaProcessService yidaProcessService = mock(YidaProcessService.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        when(yidaProcessService.startProcess(any(YidaProcessStartRequest.class))).thenReturn("process-instance-1");
+        when(employeeRosterService.findByEmployeeNo("user-001")).thenReturn(employee("dingtalk-user-001"));
+        ErpYidaPushServiceImpl service = new ErpYidaPushServiceImpl(logger, yidaProcessService,
+                Collections.<YidaFormPushHandler>emptyList(), employeeRosterService);
+        ErpYidaPushMainData mainData = new ErpYidaPushMainData();
+        mainData.setTjr("user-001");
+        mainData.setRq("2026-09-14");
+        mainData.setBm("123456");
+        mainData.setGs("ML");
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType("cpmp402");
+        request.setMainData(mainData);
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData", Collections.<Map<String, Object>>singletonList(
+                Collections.<String, Object>singletonMap("hjdh", "HJ-001")));
+        request.setDetailData(detailData);
+
+        McR result = service.push(request);
+
+        assertNotNull(result);
+        ArgumentCaptor<YidaProcessStartRequest> captor = ArgumentCaptor.forClass(YidaProcessStartRequest.class);
+        verify(yidaProcessService, times(1)).startProcess(captor.capture());
+        YidaProcessStartRequest yidaRequest = captor.getValue();
+        assertEquals("cpmp402", yidaRequest.getType());
+        assertEquals("dingtalk-user-001", yidaRequest.getUserId());
+        assertEquals("包材核价单", yidaRequest.getDataTitle());
+        assertSame(mainData, yidaRequest.getFields().get("mainData"));
+        assertSame(request.getDetailData(), yidaRequest.getFields().get("detailData"));
+        assertEquals("dingtalk-user-001", request.getOriginatorUserId());
+        Map<String, Object> pushedDetailData = mapValue(yidaRequest.getFields().get("detailData"));
+        assertEquals(Collections.singleton("detailData"), pushedDetailData.keySet());
+        Map<String, Object> pushedDetailRow = firstRow(pushedDetailData, "detailData");
+        assertEquals("dingtalk-user-001", pushedDetailRow.get("originatorUserId"));
+        assertEquals("2026-09-14", pushedDetailRow.get("applyDate"));
+        assertEquals(Collections.singletonList("123456"), pushedDetailRow.get("applyDepartment"));
+        assertEquals("ML", pushedDetailRow.get("gs"));
+        assertFalse(pushedDetailRow.containsKey("applyCompany"));
+        assertFalse(pushedDetailRow.containsKey("company"));
+        verify(logger).info(org.mockito.ArgumentMatchers.startsWith("ERP Yida push request: {}"),
+                org.mockito.ArgumentMatchers.anyString());
+        verify(logger).info(org.mockito.ArgumentMatchers.startsWith("ERP Yida push dispatched processInstanceId: {}"),
+                org.mockito.ArgumentMatchers.anyString());
+        verify(logger).info(org.mockito.ArgumentMatchers.startsWith("ERP Yida push response: {}"),
+                org.mockito.ArgumentMatchers.anyString());
+    }
+
+    @Test
+    void dispatchesMatchingTypeToYidaFormPushHandler() {
+        Logger logger = mock(Logger.class);
+        YidaProcessService yidaProcessService = mock(YidaProcessService.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        YidaFormPushHandler formPushHandler = mock(YidaFormPushHandler.class);
+        when(formPushHandler.getTypes()).thenReturn(Collections.singletonList(MlWorkOrderCraftPriceFormPushHandler.TYPE));
+        ErpYidaPushRequest request = createRequest(MlWorkOrderCraftPriceFormPushHandler.TYPE);
+        when(employeeRosterService.findByEmployeeNo("user-001")).thenReturn(employee("dingtalk-user-001"));
+        when(formPushHandler.push(request)).thenReturn("form-instance-1");
+
+        ErpYidaPushServiceImpl service = new ErpYidaPushServiceImpl(
+                logger,
+                yidaProcessService,
+                Arrays.asList(formPushHandler),
+                employeeRosterService);
+
+        McR result = service.push(request);
+
+        assertNotNull(result);
+        verify(formPushHandler, times(1)).push(request);
+        assertEquals("dingtalk-user-001", request.getOriginatorUserId());
+        Map<String, Object> pushedDetailData = mapValue(request.getDetailData());
+        assertEquals(Collections.singleton("detailData"), pushedDetailData.keySet());
+        verify(yidaProcessService, never()).startProcess(any(YidaProcessStartRequest.class));
+        verify(logger).info(org.mockito.ArgumentMatchers.startsWith("ERP Yida push dispatched formInstanceId: {}"),
+                org.mockito.ArgumentMatchers.anyString());
+    }
+
+    @Test
+    void resolvesTopLevelSubmitterAndDefaultsForMatchingFormPushHandler() {
+        Logger logger = mock(Logger.class);
+        YidaProcessService yidaProcessService = mock(YidaProcessService.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        YidaFormPushHandler formPushHandler = mock(YidaFormPushHandler.class);
+        when(formPushHandler.getTypes()).thenReturn(Collections.singletonList(ApproveOrderFormPushHandler.TYPE));
+        when(employeeRosterService.findByEmployeeNo("top-user-001")).thenReturn(employee("dingtalk-top-user-001"));
+
+        ErpYidaPushMainData mainData = new ErpYidaPushMainData();
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(ApproveOrderFormPushHandler.TYPE);
+        request.setBm("top-dept-001");
+        request.setGs("top-company-001");
+        request.setTjr("top-user-001");
+        request.setMainData(mainData);
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("xmdcdocno", "SO-001");
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData", Collections.singletonList(detailRow));
+        request.setDetailData(detailData);
+        when(formPushHandler.push(request)).thenReturn("form-instance-1");
+
+        ErpYidaPushServiceImpl service = new ErpYidaPushServiceImpl(
+                logger,
+                yidaProcessService,
+                Arrays.asList(formPushHandler),
+                employeeRosterService);
+
+        McR result = service.push(request);
+
+        assertNotNull(result);
+        assertEquals("top-user-001", request.getMainData().getTjr());
+        assertEquals("top-dept-001", request.getMainData().getBm());
+        assertEquals("top-company-001", request.getMainData().getGs());
+        assertEquals("dingtalk-top-user-001", request.getOriginatorUserId());
+        Map<String, Object> pushedDetailData = mapValue(request.getDetailData());
+        Map<String, Object> pushedDetailRow = firstRow(pushedDetailData, "detailData");
+        assertEquals("dingtalk-top-user-001", pushedDetailRow.get("originatorUserId"));
+        assertEquals(Collections.singletonList("top-dept-001"), pushedDetailRow.get("applyDepartment"));
+        assertEquals("top-company-001", pushedDetailRow.get("gs"));
+        verify(formPushHandler, times(1)).push(request);
+        verify(yidaProcessService, never()).startProcess(any(YidaProcessStartRequest.class));
+    }
+
+    @Test
+    void acceptsDetailDataOneForMatchingFormPushHandler() {
+        Logger logger = mock(Logger.class);
+        YidaProcessService yidaProcessService = mock(YidaProcessService.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        YidaFormPushHandler formPushHandler = mock(YidaFormPushHandler.class);
+        when(formPushHandler.getTypes()).thenReturn(Collections.singletonList(CustomerReleaseFormPushHandler.TYPE));
+        when(employeeRosterService.findByEmployeeNo("top-user-001")).thenReturn(employee("dingtalk-top-user-001"));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(CustomerReleaseFormPushHandler.TYPE);
+        request.setBm("top-dept-001");
+        request.setGs("top-company-001");
+        request.setTjr("top-user-001");
+        request.setMainData(new ErpYidaPushMainData());
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("xmfydocno", "CR-001");
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(detailRow));
+        request.setDetailData(detailData);
+        when(formPushHandler.push(request)).thenReturn("form-instance-1");
+
+        ErpYidaPushServiceImpl service = new ErpYidaPushServiceImpl(
+                logger,
+                yidaProcessService,
+                Arrays.asList(formPushHandler),
+                employeeRosterService);
+
+        McR result = service.push(request);
+
+        assertNotNull(result);
+        Map<String, Object> pushedDetailData = mapValue(request.getDetailData());
+        Map<String, Object> pushedDetailRow = firstRow(pushedDetailData, "detailData1");
+        assertEquals("dingtalk-top-user-001", pushedDetailRow.get("originatorUserId"));
+        assertEquals(Collections.singletonList("top-dept-001"), pushedDetailRow.get("applyDepartment"));
+        assertEquals("top-company-001", pushedDetailRow.get("gs"));
+        verify(formPushHandler, times(1)).push(request);
+        verify(yidaProcessService, never()).startProcess(any(YidaProcessStartRequest.class));
+    }
+
+    @Test
+    void rejectsMainDataTjrWhenEmployeeNoCannotBeResolved() {
+        Logger logger = mock(Logger.class);
+        YidaProcessService yidaProcessService = mock(YidaProcessService.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        ErpYidaPushRequest request = createRequest("cpmp402");
+        when(employeeRosterService.findByEmployeeNo("user-001")).thenReturn(null);
+
+        ErpYidaPushServiceImpl service = new ErpYidaPushServiceImpl(logger, yidaProcessService,
+                Collections.<YidaFormPushHandler>emptyList(), employeeRosterService);
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> service.push(request));
+
+        assertEquals("ERP Yida push mainData.tjr employee not found: user-001", exception.getMessage());
+        verify(yidaProcessService, never()).startProcess(any(YidaProcessStartRequest.class));
+    }
+
+    private EmployeeRosterInfo employee(String dingtalkId) {
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setDingtalkId(dingtalkId);
+        return employee;
+    }
+
+    private ErpYidaPushRequest createRequest(String type) {
+        ErpYidaPushMainData mainData = new ErpYidaPushMainData();
+        mainData.setTjr("user-001");
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(type);
+        request.setMainData(mainData);
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("工单号", "GD-001");
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData", Collections.singletonList(detailRow));
+        request.setDetailData(detailData);
+        return request;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> mapValue(Object value) {
+        return (Map<String, Object>) value;
+    }
+
+    private Map<String, Object> firstRow(Map<String, Object> detailData, String key) {
+        return mapValue(((List<?>) detailData.get(key)).get(0));
+    }
+}

+ 72 - 0
src/test/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImplTest.java

@@ -0,0 +1,72 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+class MingleiDocumentServiceImplTest {
+
+    /**
+     * 验证 type 为 0 时会合并当前部门与子部门成员,并保持去重顺序。
+     */
+    @Test
+    void listDepartmentUserIdsIncludesChildDepartmentsAndRemovesDuplicates() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(contactsClient.getDepartmentId_all("access-token", false, 10L)).willReturn(Arrays.asList(11L, 12L));
+        given(contactsClient.listDepartmentUserId("access-token", 10L)).willReturn(Arrays.asList("user-1", "user-shared", " "));
+        given(contactsClient.listDepartmentUserId("access-token", 11L)).willReturn(Arrays.asList("user-shared", "user-2"));
+        given(contactsClient.listDepartmentUserId("access-token", 12L)).willReturn(Arrays.asList("user-3"));
+
+        MingleiDocumentServiceImpl service = new MingleiDocumentServiceImpl(
+                mock(YDClient.class),
+                dingTalkClient,
+                contactsClient,
+                mock(DDConf.class),
+                new YidaApplicationsProperties(),
+                new ObjectMapper());
+
+        List<String> userIds = service.listDepartmentUserIds(10L, 0);
+
+        assertEquals(Arrays.asList("user-1", "user-shared", "user-2", "user-3"), userIds);
+        verify(contactsClient).getDepartmentId_all("access-token", false, 10L);
+    }
+
+    /**
+     * 验证 type 为 1 时仅查询当前部门,且不访问子部门列表。
+     */
+    @Test
+    void listDepartmentUserIdsDoesNotIncludeChildDepartmentsWhenTypeIsOne() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(contactsClient.listDepartmentUserId("access-token", 10L)).willReturn(Arrays.asList("user-1", "user-2"));
+
+        MingleiDocumentServiceImpl service = new MingleiDocumentServiceImpl(
+                mock(YDClient.class),
+                dingTalkClient,
+                contactsClient,
+                mock(DDConf.class),
+                new YidaApplicationsProperties(),
+                new ObjectMapper());
+
+        List<String> userIds = service.listDepartmentUserIds(10L, 1);
+
+        assertEquals(Arrays.asList("user-1", "user-2"), userIds);
+        verify(contactsClient, never()).getDepartmentId_all("access-token", false, 10L);
+    }
+}

+ 136 - 0
src/test/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandlerTest.java

@@ -0,0 +1,136 @@
+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.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MiscReceiptIssueFormPushHandlerTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushMapsCustomerMiscReceiptIssuePayloadToYidaFieldIds() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-1");
+        when(yidaClient.startProcess(org.mockito.ArgumentMatchers.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.setExtraField("inbaent", "87");
+        mainData.setExtraField("inbadocno", "MS-001");
+        mainData.setExtraField("inbadocdt", "2026-09-15");
+        mainData.setExtraField("inba003", "applicant-001");
+        mainData.setExtraField("inba004", "dept-code-001");
+        mainData.setExtraField("inba007", "RSN-001");
+        mainData.setExtraField("oocql004", "reason-desc-main");
+        mainData.setExtraField("inba008", "remark-001");
+        mainData.setExtraField("inba006", "ERP");
+
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("inbbent", "87");
+        detailRow.put("inbbdocno", "MS-001");
+        detailRow.put("inbbseq", "10");
+        detailRow.put("inbbua001", "SO-001");
+        detailRow.put("inbbua002", "20");
+        detailRow.put("inbb001", "ITEM-001");
+        detailRow.put("imaal003", "item-name");
+        detailRow.put("imaal004", "spec-001");
+        detailRow.put("inbb002", "feature-001");
+        detailRow.put("inbb007", "WH-001");
+        detailRow.put("inbb008", "LOC-001");
+        detailRow.put("inbb009", "LOT-001");
+        detailRow.put("inbb003", "stock-feature-001");
+        detailRow.put("inbb011", "12.5");
+        detailRow.put("inbb010", "PCS");
+        detailRow.put("inbb016", "DRSN-001");
+        detailRow.put("oocql004", "reason-desc-detail");
+        detailRow.put("inbb023", "PROJ-001");
+        detailRow.put("pjbal003", "project-desc");
+        detailRow.put("inbb017", "SRC-001");
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(detailRow));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(MiscReceiptIssueFormPushHandler.TYPE);
+        request.setBm("dept-001");
+        request.setGs("ML");
+        request.setTjr("user-001");
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+
+        MiscReceiptIssueFormPushHandler handler = new MiscReceiptIssueFormPushHandler(yidaClient, ddConf, applications);
+
+        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("system-token", body.get("systemToken"));
+        assertEquals("41603502201288023", body.get("userId"));
+        assertEquals("TPROC--MQD66371C1N84FPSJZO4W79IN4BW2AE7I08TM0", body.get("processCode"));
+        assertEquals("FORM-4B08ADABCDAA45788C043729A855E03ED76G", body.get("formUuid"));
+        assertEquals("miscReceiptIssue-MS-001", body.get("title"));
+        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("miscReceiptIssue-MS-001", formData.getString("textField_mspcpz92"));
+        assertEquals("41603502201288023", formData.getJSONArray("employeeField_msh186rr").getString(0));
+        assertEquals("ML", formData.getString("selectField_mso1em48"));
+        assertEquals("dept-001", formData.getJSONArray("departmentSelectField_msh186ry").getString(0));
+        assertEquals(Long.valueOf(1789401600000L), formData.getLong("dateField_msh186rs"));
+        assertEquals("ERP", formData.getString("textField_mt7zi4fy"));
+        assertEquals("applicant-001", formData.getString("textField_mt7zi4g0"));
+        assertEquals("dept-code-001", formData.getString("textField_mt80nq6s"));
+        assertEquals("RSN-001", formData.getString("textField_mt80nq6t"));
+        assertEquals("reason-desc-main", formData.getString("textField_mt80nq6v"));
+        assertEquals("remark-001", formData.getString("textField_mt7zi4fz"));
+
+        JSONArray detailRows = formData.getJSONArray("tableField_msh186sv");
+        assertEquals(1, detailRows.size());
+        JSONObject row = detailRows.getJSONObject(0);
+        assertEquals("10", row.getString("textField_mt7zi4g1"));
+        assertEquals("SO-001", row.getString("textField_mt80nq6y"));
+        assertEquals("20", row.getString("textField_mt80nq6z"));
+        assertEquals("ITEM-001", row.getString("textField_mt80nq70"));
+        assertEquals("item-name", row.getString("textField_mt80nq71"));
+        assertEquals("spec-001", row.getString("textField_mt80nq72"));
+        assertEquals("feature-001", row.getString("textField_mt80nq73"));
+        assertEquals("WH-001", row.getString("textField_mt80nq74"));
+        assertEquals("LOC-001", row.getString("textField_mt80nq75"));
+        assertEquals("LOT-001", row.getString("textField_mt7zi4g2"));
+        assertEquals("stock-feature-001", row.getString("textField_mt80nq76"));
+        assertEquals("12.5", row.getString("numberField_mt80nq77"));
+        assertEquals("PCS", row.getString("textField_mt6ur4w1"));
+        assertEquals("DRSN-001", row.getString("textField_mt7zi4g4"));
+        assertEquals("reason-desc-detail", row.getString("textField_mt80nq79"));
+        assertEquals("PROJ-001", row.getString("textField_mt6ur4w8"));
+        assertEquals("project-desc", row.getString("textField_mt80nq7a"));
+        assertEquals("SRC-001", row.getString("textField_mt80nq7b"));
+    }
+}

+ 184 - 0
src/test/java/com/malk/minglei/service/impl/yida/MlWorkOrderCraftPriceFormPushHandlerTest.java

@@ -0,0 +1,184 @@
+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.common.McException;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MlWorkOrderCraftPriceFormPushHandlerTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushMapsPayloadToWorkOrderCraftPriceFormFieldIds() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-1");
+        when(yidaClient.startProcess(org.mockito.ArgumentMatchers.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.setRq("1799337600000");
+        mainData.setGs("浙江明磊锂能源科技股份有限公司");
+        mainData.setBm("123456");
+
+        Map<String, Object> processRow = new LinkedHashMap<String, Object>();
+        processRow.put("工序编号", "GX-001");
+        processRow.put("名称", "装配");
+        processRow.put("数量", "2");
+        processRow.put("工价", "3.5");
+        processRow.put("合计", "7.0");
+
+        Map<String, Object> materialRow = new LinkedHashMap<String, Object>();
+        materialRow.put("项次", "10");
+        materialRow.put("料号", "MAT-001");
+        materialRow.put("品名", "电池盒");
+        materialRow.put("规格", "A1");
+        materialRow.put("用量", "1.2");
+        materialRow.put("t100id", "ML");
+        materialRow.put("车间线长", "leader-001");
+        materialRow.put("企业编号", "87");
+
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("标题", "工单工艺工价审定-GD-001");
+        detailRow.put("申请人", "3075");
+        detailRow.put("申请公司", "ML");
+        detailRow.put("申请部门", Collections.singletonList("old-dept"));
+        detailRow.put("工单号", "GD-001");
+        detailRow.put("生产令", "MO-001");
+        detailRow.put("生产料号", "ITEM-001");
+        detailRow.put("品名", "电池包");
+        detailRow.put("规格", "24V");
+        detailRow.put("组装|包装", "组装");
+        detailRow.put("固定工价", "10.5");
+        detailRow.put("总单位工价", "17.5");
+        detailRow.put("明细表1", Collections.singletonList(processRow));
+        detailRow.put("明细表2", Collections.singletonList(materialRow));
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData", Collections.singletonList(detailRow));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(MlWorkOrderCraftPriceFormPushHandler.TYPE);
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+        request.setOriginatorUserId("dingtalk-user-001");
+
+        MlWorkOrderCraftPriceFormPushHandler handler = new MlWorkOrderCraftPriceFormPushHandler(yidaClient, ddConf, applications);
+
+        assertEquals(Collections.singletonList("FORM-4403B45A7F184CB79088FCDAFD2FD925LZ3K"), 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("system-token", body.get("systemToken"));
+        assertEquals("dingtalk-user-001", body.get("userId"));
+        assertEquals("TPROC--WG966BA1VWF8Q1K4OGF8H9KW47RR3ZREH0YSM2", body.get("processCode"));
+        assertEquals("FORM-4403B45A7F184CB79088FCDAFD2FD925LZ3K", body.get("formUuid"));
+        assertEquals(true, body.get("executeExpression"));
+        assertEquals(false, body.get("noExecuteExpression"));
+        assertEquals("工单工艺工价审定-GD-001", body.get("title"));
+        assertEquals("工单工艺工价审定-GD-001", body.get("dataTitle"));
+
+        JSONObject formData = JSONObject.parseObject(String.valueOf(body.get("formDataJson")));
+        assertEquals("工单工艺工价审定-GD-001", formData.getString("textField_msh27unu"));
+        assertEquals("工单工艺工价审定-GD-001", formData.getString("textField_mspcpz92"));
+        assertEquals("dingtalk-user-001", formData.getJSONArray("employeeField_msh186rr").getString(0));
+        assertFalse(formData.containsKey("selectField_mso1em48"));
+        assertEquals(mainData.getGs(), formData.getString("textField_mu1yk6l5"));
+        assertEquals("123456", formData.getJSONArray("departmentSelectField_msh186ry").getString(0));
+        assertEquals(Long.valueOf(1799337600000L), formData.getLong("dateField_msh186rs"));
+        assertEquals("GD-001", formData.getString("textField_msy0zj5i"));
+        assertEquals("MO-001", formData.getString("textField_msy0zj5j"));
+        assertEquals("ITEM-001", formData.getString("textField_msy0zj5l"));
+        assertEquals("电池包", formData.getString("textField_msy0zj5m"));
+        assertEquals("24V", formData.getString("textField_msy0zj5n"));
+        assertEquals("组装", formData.getString("textField_msy0zj5o"));
+        assertEquals("10.5", formData.getString("numberField_msy0zj5q"));
+        assertEquals("17.5", formData.getString("numberField_msy0zj5z"));
+
+        JSONArray processRows = formData.getJSONArray("tableField_msh186sv");
+        assertEquals(1, processRows.size());
+        JSONObject savedProcessRow = processRows.getJSONObject(0);
+        assertEquals("GX-001", savedProcessRow.getString("textField_msy0zj60"));
+        assertEquals("装配", savedProcessRow.getString("textField_msy0zj61"));
+        assertEquals("2", savedProcessRow.getString("numberField_msy0zj62"));
+        assertEquals("3.5", savedProcessRow.getString("numberField_msy0zj63"));
+        assertEquals("7.0", savedProcessRow.getString("numberField_msy0zj64"));
+
+        JSONArray materialRows = formData.getJSONArray("tableField_msh186t3");
+        assertEquals(1, materialRows.size());
+        JSONObject savedMaterialRow = materialRows.getJSONObject(0);
+        assertEquals("10", savedMaterialRow.getString("textField_msy0zj65"));
+        assertEquals("MAT-001", savedMaterialRow.getString("textField_msy0zj66"));
+        assertEquals("电池盒", savedMaterialRow.getString("textField_msy0zj67"));
+        assertEquals("A1", savedMaterialRow.getString("textField_msy0zj68"));
+        assertEquals("1.2", savedMaterialRow.getString("textField_msy0zj69"));
+        assertEquals("ML", savedMaterialRow.getString("textField_msy0zj6b"));
+        assertEquals("leader-001", savedMaterialRow.getString("textField_msy0zj6c"));
+        assertEquals("87", savedMaterialRow.getString("textField_msy0zj6h"));
+    }
+
+    @Test
+    void pushWrapsUnexpectedYidaClientException() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        when(yidaClient.startProcess(org.mockito.ArgumentMatchers.anyMap())).thenThrow(new RuntimeException("invalid select option"));
+
+        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.setRq("2026-09-08");
+        mainData.setGs("ML");
+        mainData.setBm("962575063");
+
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("title", "work-order-title");
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData", Collections.singletonList(detailRow));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(MlWorkOrderCraftPriceFormPushHandler.TYPE);
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+        request.setOriginatorUserId("dingtalk-user-001");
+
+        MlWorkOrderCraftPriceFormPushHandler handler = new MlWorkOrderCraftPriceFormPushHandler(yidaClient, ddConf, applications);
+
+        McException exception = assertThrows(McException.class, () -> handler.push(request));
+
+        assertEquals("DINGTALK_YIDA_ERROR", exception.getCode());
+        assertEquals("宜搭流程发起失败: invalid select option", exception.getMessage());
+    }
+}

+ 46 - 0
src/test/java/com/malk/minglei/service/impl/yida/YidaProcessServiceImplTest.java

@@ -0,0 +1,46 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.minglei.dto.YidaProcessStartRequest;
+import com.malk.minglei.service.yida.YidaProcessHandler;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class YidaProcessServiceImplTest {
+
+    @Test
+    void startProcessDispatchesRequestToMatchingHandler() {
+        YidaProcessHandler bomHandler = mock(YidaProcessHandler.class);
+        given(bomHandler.getType()).willReturn(BomYidaProcessHandler.TYPE);
+
+        YidaProcessStartRequest request = new YidaProcessStartRequest();
+        request.setType(" " + BomYidaProcessHandler.TYPE.toLowerCase(Locale.ROOT) + " ");
+        given(bomHandler.startProcess(same(request))).willReturn("process-instance-1");
+
+        YidaProcessServiceImpl service = new YidaProcessServiceImpl(Collections.singletonList(bomHandler));
+
+        assertEquals("process-instance-1", service.startProcess(request));
+        verify(bomHandler).startProcess(request);
+    }
+
+    @Test
+    void startProcessRejectsUnsupportedType() {
+        YidaProcessHandler bomHandler = mock(YidaProcessHandler.class);
+        given(bomHandler.getType()).willReturn(BomYidaProcessHandler.TYPE);
+
+        YidaProcessStartRequest request = new YidaProcessStartRequest();
+        request.setType("PURCHASE");
+        YidaProcessServiceImpl service = new YidaProcessServiceImpl(Collections.singletonList(bomHandler));
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> service.startProcess(request));
+        assertEquals("Unsupported Yida process type: PURCHASE", exception.getMessage());
+    }
+}

+ 27 - 0
src/test/js/mjs-sign.test.js

@@ -0,0 +1,27 @@
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const assert = require('assert');
+
+const libraryPath = path.resolve(__dirname, '../../main/resources/static/mjs/mjs.min.js');
+const source = fs.readFileSync(libraryPath, 'utf8');
+const signatureModule = source.slice(source.indexOf('// ZTNA signature helper'));
+const sandbox = { mjs: {} };
+
+vm.runInNewContext(signatureModule, sandbox, { filename: libraryPath });
+
+assert.strictEqual(typeof sandbox.mjs.sign, 'function');
+assert.strictEqual(
+  sandbox.mjs.sign('testAccessKeyId', 'testAccessKeySecret', 'gateway.example.com:8443', '', 1714268844854),
+  'NJxwxDOUcz+DDQ9XhzHflgz2gxOVC2RcKBXXySWm+dA='
+);
+assert.throws(
+  () => sandbox.mjs.sign('', 'testAccessKeySecret', 'gateway.example.com:8443', '', 1714268844854),
+  /accessKeyId is required/
+);
+assert.throws(
+  () => sandbox.mjs.sign('testAccessKeyId', '', 'gateway.example.com:8443', '', 1714268844854),
+  /accessKeySecret is required/
+);
+
+console.log('mjs.sign signature test passed');