ht 5 日 前
コミット
c18f8e51e7
共有66 個のファイルを変更した8082 個の追加0 個の削除を含む
  1. 33 0
      IntegrateDingTalkOA/.gitignore
  2. 88 0
      IntegrateDingTalkOA/pom.xml
  3. 13 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/IntegrateDingTalkOaApplication.java
  4. 96 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/common/ApiResponse.java
  5. 18 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/config/DDConfig.java
  6. 17 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/config/h3yunConfig.java
  7. 70 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/controller/IntegrateDingTalkOAController.java
  8. 19 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/entity/goOutApplicationForm.java
  9. 159 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/scheduleTask/DynamicScheduleConfig.java
  10. 39 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/scheduleTask/ScheduleModuleExecutor.java
  11. 128 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/scheduleTask/ScheduleTaskConfig.java
  12. 15 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/DingTalkOA.java
  13. 9 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/IntegrateCY.java
  14. 21 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/h3yunClient.java
  15. 117 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/impl/DingTalkOAImpl.java
  16. 977 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/impl/IntegrateCYImpl.java
  17. 275 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/impl/h3yunClientImpl.java
  18. 192 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/util/HttpRequestUtil.java
  19. 29 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/util/ObjectMapUtil.java
  20. 157 0
      IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/util/TimeUtil.java
  21. 12 0
      IntegrateDingTalkOA/src/main/resources/application.yml
  22. 75 0
      IntegrateDingTalkOA/src/test/java/com/example/integratedingtalkoa/IntegrateDingTalkOaApplicationTests.java
  23. 74 0
      IntegrateDingTalkOA/src/test/java/com/example/integratedingtalkoa/service/impl/ProcessInstanceIdParsingTest.java
  24. 179 0
      IntegrateDingTalkOA/src/test/test.json
  25. 175 0
      mjava-jinlun/README.md
  26. 18 0
      mjava-minglei/src/main/java/com/malk/minglei/config/DingTalkRosterConfiguration.java
  27. 45 0
      mjava-minglei/src/main/java/com/malk/minglei/config/EmployeeRosterConfiguration.java
  28. 106 0
      mjava-minglei/src/main/java/com/malk/minglei/config/EmployeeRosterProperties.java
  29. 46 0
      mjava-minglei/src/main/java/com/malk/minglei/controller/EmployeeRosterController.java
  30. 95 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/EmployeeRosterInfo.java
  31. 20 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/EmployeeRosterQueryRequest.java
  32. 38 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/EmployeeRosterSyncResult.java
  33. 11 0
      mjava-minglei/src/main/java/com/malk/minglei/service/employee/DingTalkEmployeeRosterClient.java
  34. 15 0
      mjava-minglei/src/main/java/com/malk/minglei/service/employee/EmployeeRosterService.java
  35. 123 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/DingTalkEmployeeRosterClientImpl.java
  36. 86 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRecord.java
  37. 116 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepository.java
  38. 31 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterScheduler.java
  39. 532 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImpl.java
  40. 132 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/port/MingleiPortServiceImpl.java
  41. 451 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandler.java
  42. 89 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/BomYidaProcessHandler.java
  43. 238 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/Cpmp402YidaProcessHandler.java
  44. 29 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/DingTalkYidaProcessOpenApiClient.java
  45. 275 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImpl.java
  46. 357 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImpl.java
  47. 463 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/MlWorkOrderCraftPriceFormPushHandler.java
  48. 54 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/YidaProcessServiceImpl.java
  49. 19 0
      mjava-minglei/src/main/java/com/malk/minglei/service/port/MingleiPortService.java
  50. 18 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/ErpYidaPushService.java
  51. 35 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/MingleiDocumentService.java
  52. 15 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/YidaFormPushHandler.java
  53. 25 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/YidaProcessHandler.java
  54. 13 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/YidaProcessOpenApiClient.java
  55. 14 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/YidaProcessService.java
  56. 17 0
      mjava-minglei/src/main/resources/db/employee_roster.sql
  57. 58 0
      mjava-minglei/src/test/java/com/malk/minglei/controller/EmployeeRosterControllerTest.java
  58. 480 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterLiveTest.java
  59. 206 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java
  60. 74 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/port/MingleiPortServiceImplTest.java
  61. 150 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandlerTest.java
  62. 106 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/Cpmp402YidaProcessHandlerTest.java
  63. 195 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImplTest.java
  64. 72 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImplTest.java
  65. 182 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/MlWorkOrderCraftPriceFormPushHandlerTest.java
  66. 46 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/YidaProcessServiceImplTest.java

+ 33 - 0
IntegrateDingTalkOA/.gitignore

@@ -0,0 +1,33 @@
+HELP.md
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/

+ 88 - 0
IntegrateDingTalkOA/pom.xml

@@ -0,0 +1,88 @@
+<?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>
+    <groupId>com.example</groupId>
+    <artifactId>IntegrateDingTalkOA</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <name>IntegrateDingTalkOA</name>
+    <description>IntegrateDingTalkOA</description>
+    <properties>
+        <java.version>1.8</java.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <spring-boot.version>2.6.13</spring-boot.version>
+    </properties>
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.example</groupId>
+            <artifactId>gson</artifactId>
+            <version>2.8.0</version>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <version>1.18.34</version>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>${spring-boot.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.8.1</version>
+                <configuration>
+                    <source>1.8</source>
+                    <target>1.8</target>
+                    <encoding>UTF-8</encoding>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>${spring-boot.version}</version>
+                <configuration>
+                    <mainClass>com.example.integratedingtalkoa.IntegrateDingTalkOaApplication</mainClass>
+                    <skip>true</skip>
+                </configuration>
+                <executions>
+                    <execution>
+                        <id>repackage</id>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>

+ 13 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/IntegrateDingTalkOaApplication.java

@@ -0,0 +1,13 @@
+package com.example.integratedingtalkoa;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class IntegrateDingTalkOaApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(IntegrateDingTalkOaApplication.class, args);
+    }
+
+}

+ 96 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/common/ApiResponse.java

@@ -0,0 +1,96 @@
+package com.example.integratedingtalkoa.common;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 统一接口返回体
+ *
+ * @param <T> data 数据类型
+ */
+public class ApiResponse<T> {
+
+    /**
+     * 成功状态码
+     */
+    public static final int SUCCESS_CODE = 200;
+
+    /**
+     * 失败状态码
+     */
+    public static final int FAIL_CODE = 500;
+
+    private Integer code;
+
+    private String message;
+
+    private T data;
+
+    public ApiResponse() {
+    }
+
+    public ApiResponse(Integer code, String message, T data) {
+        this.code = code;
+        this.message = message;
+        this.data = data;
+    }
+
+    /**
+     * 成功返回,携带数据
+     */
+    public static <T> ApiResponse<T> success(T data) {
+        return new ApiResponse<T>(SUCCESS_CODE, "success", data);
+    }
+
+    /**
+     * 成功返回,自定义消息并携带数据
+     */
+    public static <T> ApiResponse<T> success(String message, T data) {
+        return new ApiResponse<T>(SUCCESS_CODE, message, data);
+    }
+
+    /**
+     * 成功返回空数组
+     */
+    public static ApiResponse<List<Object>> successEmptyList() {
+        return new ApiResponse<List<Object>>(SUCCESS_CODE, "success", new ArrayList<Object>());
+    }
+
+    /**
+     * 失败返回,默认 code=500
+     */
+    public static <T> ApiResponse<T> fail(String message) {
+        return new ApiResponse<T>(FAIL_CODE, message, null);
+    }
+
+    /**
+     * 失败返回,自定义 code
+     */
+    public static <T> ApiResponse<T> fail(Integer code, String message) {
+        return new ApiResponse<T>(code, message, null);
+    }
+
+    public Integer getCode() {
+        return code;
+    }
+
+    public void setCode(Integer code) {
+        this.code = code;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    public T getData() {
+        return data;
+    }
+
+    public void setData(T data) {
+        this.data = data;
+    }
+}

+ 18 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/config/DDConfig.java

@@ -0,0 +1,18 @@
+package com.example.integratedingtalkoa.config;
+
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Data
+@Component
+@ConfigurationProperties(prefix = "dingtalk")
+public class DDConfig {
+
+    private String appKey;
+    private String appSecret;
+    private String wcProcessCode;
+
+}

+ 17 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/config/h3yunConfig.java

@@ -0,0 +1,17 @@
+package com.example.integratedingtalkoa.config;
+
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Data
+@Component
+@ConfigurationProperties(prefix = "h3yun")
+public class h3yunConfig {
+
+    private String EngineCode;
+    private String EngineSecret;
+    private String url;
+}

+ 70 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/controller/IntegrateDingTalkOAController.java

@@ -0,0 +1,70 @@
+package com.example.integratedingtalkoa.controller;
+
+import com.example.integratedingtalkoa.common.ApiResponse;
+import com.example.integratedingtalkoa.service.impl.DingTalkOAImpl;
+import com.example.integratedingtalkoa.service.impl.IntegrateCYImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 钉钉 OA 与氚云集成接口控制器。
+ *
+ * <p>用于对外提供创建、查询、批量创建、更新氚云外出单据等接口,
+ * 并统一使用 {@link ApiResponse} 封装接口返回结果。</p>
+ */
+@RestController
+public class IntegrateDingTalkOAController {
+
+    @Autowired
+    private IntegrateCYImpl integrateCYImpl;
+
+    @Autowired
+    private DingTalkOAImpl dingTalkOAImpl;
+
+    /**
+     * 手动触发钉钉 OA 外出审批同步到氚云。
+     *
+     * @return 同步结果
+     */
+    @PostMapping("/syneDDOAProcessInstancesToH3yun")
+    public ApiResponse<String> syneDDOAProcessInstancesToH3yun() {
+        try {
+            String result = integrateCYImpl.syncGoOutProcessInstancesByCompare();
+            return ApiResponse.success(result);
+        } catch (Exception e) {
+            return ApiResponse.fail("同步钉钉 OA 外出审批到氚云失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 手动同步钉钉已通过的出差审批单到氚云出差记录。
+     *
+     * @return 同步结果
+     */
+    @PostMapping("/syncDingTalkBusinessTripToH3yun")
+    public ApiResponse<String> syncDingTalkBusinessTripToH3yun() {
+        try {
+            String result = integrateCYImpl.syncBusinessTripProcessInstances();
+            return ApiResponse.success(result);
+        } catch (Exception e) {
+            return ApiResponse.fail("同步钉钉出差审批到氚云失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 每日凌晨零点零时零分零秒自动同步钉钉 OA 外出审批到氚云。
+     */
+    @Scheduled(cron = "0 0 0 * * ?")
+    public void scheduledSyneDDOAProcessInstancesToH3yun() {
+        try {
+            String result = integrateCYImpl.syncGoOutProcessInstancesByCompare();
+            System.out.println("每日自动同步钉钉 OA 外出审批到氚云完成:" + result);
+        } catch (Exception e) {
+            System.out.println("每日自动同步钉钉 OA 外出审批到氚云失败:" + e.getMessage());
+            e.printStackTrace();
+        }
+    }
+
+}

+ 19 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/entity/goOutApplicationForm.java

@@ -0,0 +1,19 @@
+package com.example.integratedingtalkoa.entity;
+
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class goOutApplicationForm{
+
+    //外出事由
+    String goOutReason;
+    Date stratTime;
+    Date endTime;
+    String duration;
+    String goOutExplanation;
+    String isTemporaryApplication;
+    String reason;
+    String image;
+}

+ 159 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/scheduleTask/DynamicScheduleConfig.java

@@ -0,0 +1,159 @@
+package com.example.integratedingtalkoa.scheduleTask;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.SchedulingConfigurer;
+import org.springframework.scheduling.Trigger;
+import org.springframework.scheduling.config.ScheduledTaskRegistrar;
+import org.springframework.scheduling.support.CronTrigger;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 动态定时任务配置类。
+ *
+ * <p>应用启动时读取 ScheduleTaskConfig 中定义的任务配置,
+ * 支持按 cron 表达式注册循环任务,也支持按指定时间注册一次性任务,
+ * 并按 module 调用对应业务模块。</p>
+ */
+@Configuration
+@EnableScheduling
+public class DynamicScheduleConfig implements SchedulingConfigurer {
+
+    @Autowired
+    private ScheduleTaskConfig scheduleTaskConfig;
+
+    @Autowired
+    private ScheduleModuleExecutor scheduleModuleExecutor;
+
+    /**
+     * 根据配置文件注册定时任务。
+     *
+     * @param taskRegistrar Spring 定时任务注册器
+     */
+    @Override
+    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
+        if (!Boolean.TRUE.equals(scheduleTaskConfig.getEnabled())) {
+            System.out.println("定时任务总开关未开启,跳过任务注册");
+            return;
+        }
+
+        List<ScheduleTaskConfig.Task> tasks = scheduleTaskConfig.getTasks();
+        if (tasks == null || tasks.isEmpty()) {
+            System.out.println("未配置定时任务列表,跳过任务注册");
+            return;
+        }
+
+        for (ScheduleTaskConfig.Task task : tasks) {
+            if (!isValidTask(task)) {
+                continue;
+            }
+
+            taskRegistrar.addTriggerTask(
+                    () -> executeTask(task),
+                    buildTrigger(task)
+            );
+
+            System.out.println("已注册定时任务:name=" + task.getName()
+                    + ", module=" + task.getModule()
+                    + ", executeOnce=" + task.getExecuteOnce()
+                    + ", cron=" + task.getCron()
+                    + ", executeTime=" + task.getExecuteTime());
+        }
+    }
+
+    /**
+     * 根据任务配置构建触发器。
+     *
+     * @param task 定时任务配置
+     * @return 任务触发器
+     */
+    private Trigger buildTrigger(ScheduleTaskConfig.Task task) {
+        if (Boolean.TRUE.equals(task.getExecuteOnce())) {
+            AtomicBoolean executed = new AtomicBoolean(false);
+            return triggerContext -> {
+                if (executed.get()) {
+                    return null;
+                }
+
+                LocalDateTime executeTime = task.getExecuteTime();
+                if (executeTime == null) {
+                    return null;
+                }
+
+                if (executeTime.isBefore(LocalDateTime.now())) {
+                    System.out.println("一次性定时任务执行时间已过,跳过注册:name=" + task.getName()
+                            + ", executeTime=" + executeTime);
+                    executed.set(true);
+                    return null;
+                }
+
+                executed.set(true);
+                return Date.from(executeTime.atZone(ZoneId.systemDefault()).toInstant());
+            };
+        }
+
+        return triggerContext -> new CronTrigger(task.getCron()).nextExecutionTime(triggerContext);
+    }
+
+    /**
+     * 执行单个定时任务。
+     *
+     * @param task 定时任务配置
+     */
+    private void executeTask(ScheduleTaskConfig.Task task) {
+        String taskName = task.getName();
+        String module = task.getModule();
+
+        try {
+            System.out.println("开始执行定时任务:name=" + taskName
+                    + ", module=" + module
+                    + ", time=" + LocalDateTime.now());
+
+            String result = scheduleModuleExecutor.execute(module);
+
+            System.out.println("定时任务执行完成:name=" + taskName
+                    + ", module=" + module
+                    + ", result=" + result);
+        } catch (Exception e) {
+            System.out.println("定时任务执行失败:name=" + taskName
+                    + ", module=" + module
+                    + ", error=" + e.getMessage());
+            e.printStackTrace();
+        }
+    }
+
+    /**
+     * 校验单个任务配置是否有效。
+     *
+     * @param task 定时任务配置
+     * @return true 表示有效,false 表示跳过注册
+     */
+    private boolean isValidTask(ScheduleTaskConfig.Task task) {
+        if (task == null) {
+            return false;
+        }
+        if (!Boolean.TRUE.equals(task.getEnabled())) {
+            return false;
+        }
+        if (Boolean.TRUE.equals(task.getExecuteOnce())) {
+            if (task.getExecuteTime() == null) {
+                System.out.println("一次性定时任务 executeTime 为空,跳过注册:name=" + task.getName());
+                return false;
+            }
+        } else if (task.getCron() == null || task.getCron().trim().length() == 0) {
+            System.out.println("循环定时任务 cron 为空,跳过注册:name=" + task.getName());
+            return false;
+        }
+        if (task.getModule() == null || task.getModule().trim().length() == 0) {
+            System.out.println("定时任务 module 为空,跳过注册:name=" + task.getName());
+            return false;
+        }
+        return true;
+    }
+}

+ 39 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/scheduleTask/ScheduleModuleExecutor.java

@@ -0,0 +1,39 @@
+package com.example.integratedingtalkoa.scheduleTask;
+
+import com.example.integratedingtalkoa.service.impl.IntegrateCYImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * 定时任务模块执行器。
+ *
+ * <p>根据配置中的模块编码分发到不同的业务方法,后续新增业务模块时,
+ * 只需要在此处新增 module 与方法的映射关系。</p>
+ */
+@Component
+public class ScheduleModuleExecutor {
+
+    @Autowired
+    private IntegrateCYImpl integrateCYImpl;
+
+    /**
+     * 执行指定模块。
+     *
+     * @param module 模块编码
+     * @return 模块执行结果
+     */
+    public String execute(String module) {
+        if (module == null || module.trim().length() == 0) {
+            throw new IllegalArgumentException("定时任务模块编码不能为空");
+        }
+
+        switch (module) {
+            case "DINGTALK_SYNC_TO_H3YUN":
+                return integrateCYImpl.syncGoOutProcessInstancesByCompare();
+            case "DINGTALK_BUSINESS_TRIP_SYNC_TO_H3YUN":
+                return integrateCYImpl.syncBusinessTripProcessInstances();
+            default:
+                throw new IllegalArgumentException("未配置的定时任务模块:" + module);
+        }
+    }
+}

+ 128 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/scheduleTask/ScheduleTaskConfig.java

@@ -0,0 +1,128 @@
+package com.example.integratedingtalkoa.scheduleTask;
+
+import lombok.Data;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 定时任务配置类。
+ *
+ * <p>用于在代码中统一配置定时任务总开关和任务列表,支持指定什么时间启动什么业务模块。</p>
+ */
+@Data
+@Component
+public class ScheduleTaskConfig {
+
+    /**
+     * 定时任务总开关,true 表示启用,false 表示关闭全部配置任务。
+     */
+    private Boolean enabled = true;
+
+    /**
+     * 定时任务列表。
+     */
+    private List<Task> tasks = buildTasks();
+
+    /**
+     * 构建定时任务配置列表。
+     *
+     * @return 定时任务配置列表
+     */
+    private List<Task> buildTasks() {
+        List<Task> taskList = new ArrayList<>();
+
+        taskList.add(buildTask(
+                "syneDDOAProcessInstancesToH3yun",
+                false,
+                "0 0 0 * * ?",
+                "DINGTALK_SYNC_TO_H3YUN"
+        ));
+
+        taskList.add(buildTask(
+                "syncDingTalkBusinessTripToH3yun",
+                true,
+                "0 10 0 * * ?",
+                "DINGTALK_BUSINESS_TRIP_SYNC_TO_H3YUN"
+        ));
+
+        return taskList;
+    }
+
+    /**
+     * 构建单个循环定时任务配置。
+     *
+     * @param name 任务名称
+     * @param enabled 是否启用当前任务
+     * @param cron cron 表达式
+     * @param module 业务模块编码
+     * @return 定时任务配置
+     */
+    private Task buildTask(String name, Boolean enabled, String cron, String module) {
+        Task task = new Task();
+        task.setName(name);
+        task.setEnabled(enabled);
+        task.setCron(cron);
+        task.setModule(module);
+        task.setExecuteOnce(false);
+        return task;
+    }
+
+    /**
+     * 构建单个一次性定时任务配置。
+     *
+     * @param name 任务名称
+     * @param enabled 是否启用当前任务
+     * @param executeTime 一次性任务执行时间
+     * @param module 业务模块编码
+     * @return 定时任务配置
+     */
+    private Task buildOnceTask(String name, Boolean enabled, LocalDateTime executeTime, String module) {
+        Task task = new Task();
+        task.setName(name);
+        task.setEnabled(enabled);
+        task.setExecuteOnce(true);
+        task.setExecuteTime(executeTime);
+        task.setModule(module);
+        return task;
+    }
+
+    /**
+     * 单个定时任务配置。
+     */
+    @Data
+    public static class Task {
+
+        /**
+         * 任务名称,用于日志识别。
+         */
+        private String name;
+
+        /**
+         * 是否启用当前任务。
+         */
+        private Boolean enabled = true;
+
+        /**
+         * cron 表达式,控制循环任务执行时间。
+         */
+        private String cron;
+
+        /**
+         * 是否只执行一次,true 表示按 executeTime 执行一次,false 表示按 cron 循环执行。
+         */
+        private Boolean executeOnce = false;
+
+        /**
+         * 一次性任务执行时间,仅 executeOnce 为 true 时生效。
+         */
+        private LocalDateTime executeTime;
+
+        /**
+         * 要执行的业务模块编码。
+         */
+        private String module;
+    }
+}

+ 15 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/DingTalkOA.java

@@ -0,0 +1,15 @@
+package com.example.integratedingtalkoa.service;
+
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+@Service
+public interface DingTalkOA {
+
+    String getAccessToken();
+    String getInstanceIds(Map<String, Object> params, String accessToken);
+    String getProcessInstances(String processInstanceId,String accessToken);
+
+}

+ 9 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/IntegrateCY.java

@@ -0,0 +1,9 @@
+package com.example.integratedingtalkoa.service;
+
+import org.springframework.stereotype.Service;
+
+@Service
+public interface IntegrateCY {
+
+
+}

+ 21 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/h3yunClient.java

@@ -0,0 +1,21 @@
+package com.example.integratedingtalkoa.service;
+
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+@Service
+public interface h3yunClient {
+    Map CreateBizObject(String SchemaCode,Map<String,String> map);
+
+    Map CreateBizObjects(String SchemaCode, List<Map<String,Object>> listMap);
+
+    Map LoadBizObject(String SchemaCode,String bizObjectId);
+
+    Map LoadBizObjects(String schemaCode, Map<String, Object> filter);
+
+    Map UpdateBizObject(String SchemaCode,String ObjectId,Map<String,Object> map);
+
+    Map RemoveBizObject(String SchemaCode, String BizObjectId);
+}

+ 117 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/impl/DingTalkOAImpl.java

@@ -0,0 +1,117 @@
+package com.example.integratedingtalkoa.service.impl;
+
+import com.example.integratedingtalkoa.config.DDConfig;
+import com.example.integratedingtalkoa.service.DingTalkOA;
+import com.example.integratedingtalkoa.util.HttpRequestUtil;
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class DingTalkOAImpl implements DingTalkOA {
+
+    @Autowired
+    DDConfig dingdingConfig;
+
+    @Override
+    public String getAccessToken() {
+        Gson gson = new Gson();
+
+        Map<String,String> params = new HashMap<>();
+        params.put("appKey", dingdingConfig.getAppKey());
+        params.put("appSecret", dingdingConfig.getAppSecret());
+
+        try {
+            String result = HttpRequestUtil.sendPost("https://api.dingtalk.com/v1.0/oauth2/accessToken", gson.toJson(params));
+            Map<String, Object> map = gson.fromJson(result, Map.class);
+            String accessToken = (String) map.get("accessToken");
+            if (accessToken == null || accessToken.trim().length() == 0) {
+                accessToken = (String) map.get("access_token");
+            }
+            if (accessToken == null || accessToken.trim().length() == 0) {
+                throw new RuntimeException("获取钉钉 accessToken 失败,响应结果:" + result);
+            }
+            return accessToken;
+        }catch (Exception e){
+            throw new RuntimeException("钉钉接口调用失败:" + e);
+        }
+    }
+
+    @Override
+    public String getInstanceIds(Map<String,Object> params, String accessToken) {
+        Gson gson = new Gson();
+
+        if (accessToken == null || accessToken.trim().length() == 0) {
+            throw new RuntimeException("获取钉钉审批实例ID失败:accessToken 为空");
+        }
+
+        Map<String,String> header = new HashMap<>();
+        header.put("x-acs-dingtalk-access-token", accessToken);
+
+        String requestBody = gson.toJson(params);
+        log.info("获取钉钉审批实例ID请求参数:{}", requestBody);
+
+        try {
+            String sendResult = HttpRequestUtil.sendPost("https://api.dingtalk.com/v1.0/workflow/processes/instanceIds/query", requestBody, header);
+            log.info("获取钉钉审批实例ID响应结果:{}", sendResult);
+            return sendResult;
+        }catch (Exception e){
+            throw new RuntimeException("获取钉钉审批实例ID响应结果失败:" + e);
+        }
+    }
+
+
+
+    /**
+     * 从 JSON 对象中获取数组字段。
+     *
+     * @param jsonObject JSON 对象
+     * @param fieldName 字段名
+     * @return JSON 数组;字段不存在或不是数组时返回 null
+     */
+    private JsonArray getJsonArray(JsonObject jsonObject, String fieldName) {
+        if (jsonObject == null || !jsonObject.has(fieldName) || !jsonObject.get(fieldName).isJsonArray()) {
+            return null;
+        }
+        return jsonObject.getAsJsonArray(fieldName);
+    }
+
+    /**
+     * 从 JSON 对象中获取数组字段。
+     *
+     * @param processInstanceId JSON 对象
+     * @param accessToken 字段名
+     * @return 钉钉实例详情
+     */
+    @Override
+    public String getProcessInstances(String processInstanceId,String accessToken) {
+        Gson gson = new Gson();
+
+        if (accessToken == null || accessToken.trim().length() == 0) {
+            throw new RuntimeException("获取钉钉审批实例详情失败:accessToken 为空");
+        }
+
+        Map<String,String> header = new HashMap<>();
+        header.put("x-acs-dingtalk-access-token", accessToken);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("processInstanceId", processInstanceId);
+
+        try {
+            log.info("获取钉钉审批实例详情请求参数:{}", gson.toJson(params));
+            String sendResult = HttpRequestUtil.sendGet("https://api.dingtalk.com/v1.0/workflow/processInstances", params, header);
+            log.info("获取钉钉审批实例详情响应结果:{}", sendResult);
+            return sendResult;
+        }catch (Exception e){
+            throw new RuntimeException("钉钉审批实例查询结果失败:" + e);
+        }
+    }
+
+}

+ 977 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/impl/IntegrateCYImpl.java

@@ -0,0 +1,977 @@
+package com.example.integratedingtalkoa.service.impl;
+
+
+import com.example.integratedingtalkoa.config.DDConfig;
+import com.example.integratedingtalkoa.service.IntegrateCY;
+import com.example.integratedingtalkoa.util.TimeUtil;
+import com.google.gson.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+
+/**
+ * 钉钉 OA 与氚云业务集成服务实现类。
+ *
+ * <p>负责组装氚云 OpenApi 所需的业务参数,并调用氚云客户端完成外出单据的
+ * 新增、查询、批量创建、更新等操作。</p>
+ */
+@Slf4j
+@Service
+public class IntegrateCYImpl implements IntegrateCY {
+    private static final String BUSINESS_TRIP_PROCESS_CODE = "PROC-FF6Y696SO2-C63CMT87Q01PP2VN8V5D2-OVJZHHQI-45";
+    private static final String BUSINESS_TRIP_SCHEMA_CODE = "D000867smef05bd07fc4b43dfb179b72eb8fccf1b";
+    private static final String BUSINESS_TRIP_PROCESS_INSTANCE_ID_FIELD_CODE = "F0000018";
+    private static final String BUSINESS_TRIP_REASON_FIELD_CODE = "F0000004";
+    private static final String BUSINESS_TRIP_ITINERARY_FIELD_CODE = "D00086747ee2d03c3b3438ca4c41c7761451444";
+    private static final String BUSINESS_TRIP_DAYS_FIELD_CODE = "F0000001";
+    private static final String BUSINESS_TRIP_TEMPORARY_FIELD_CODE = "F0000017";
+    private static final String BUSINESS_TRIP_REMARK_FIELD_CODE = "F0000012";
+    private static final String BUSINESS_TRIP_COST_CENTER_FIELD_CODE = "F0000013";
+    private static final String BUSINESS_TRIP_INVOICE_TITLE_FIELD_CODE = "F0000014";
+    private static final String BUSINESS_TRIP_COMPANION_FIELD_CODE = "F0000015";
+    private static final String BUSINESS_TRIP_DEPARTMENT_FIELD_CODE = "F0000016";
+    private static final String BUSINESS_TRIP_APPLICANT_FIELD_CODE = "F0000019";
+    private static final String BUSINESS_TRIP_APPLY_DATE_FIELD_CODE = "F0000021";
+
+    private final Gson gson = new Gson();
+
+    @Autowired
+    private h3yunClientImpl h3yunClientImpl;
+
+    @Autowired
+    private DingTalkOAImpl dingTalkOAImpl;
+
+    @Autowired
+    private DDConfig ddConfig;
+
+    /**
+     * 同步钉钉 OA 外出审批实例到氚云。
+     *
+     * <p>同步流程:</p>
+     * <ol>
+     *     <li>获取钉钉 accessToken;</li>
+     *     <li>查询最近一天已完成的外出审批实例 ID;</li>
+     *     <li>根据实例 ID 查询钉钉审批详情;</li>
+     *     <li>转换为氚云外出表单字段;</li>
+     *     <li>根据钉钉审批实例 ID 查询氚云是否已存在;</li>
+     *     <li>不存在则新增,已存在则更新。</li>
+     * </ol>
+     *
+     * @return 同步执行结果说明
+     */
+//    public String syneDDOAProcessInstancesToH3yun() {
+//
+//        //首先获取钉钉OA中的数据
+//        String accessToken = dingTalkOAImpl.getAccessToken();
+//
+//        Long nextToken = 0L;
+//        while (nextToken != null) {
+//            Map<String, Object> ddParams = new HashMap<>();
+//            Long endTime = TimeUtil.getCurrentTimestamp();
+//            ddParams.put("processCode", ddConfig.getWcProcessCode());
+//            ddParams.put("startTime", TimeUtil.minusDays(endTime, 30));
+//            ddParams.put("endTime", endTime);
+//            ddParams.put("maxResults", 20L);
+//            ddParams.put("statuses", Arrays.asList("COMPLETED"));
+//
+//            String instanceIds = dingTalkOAImpl.getInstanceIds(ddParams, accessToken);
+//            JsonParser jsonParser = new JsonParser();
+//            JsonObject rootObject = jsonParser.parse(instanceIds).getAsJsonObject();
+//            Boolean success = rootObject.get("success").getAsBoolean();
+//            List<JsonElement> processInstanceIds = new ArrayList();
+//            if (success) {
+//                JsonObject result = rootObject.get("result").getAsJsonObject();
+//                JsonArray listArray = result.get("list").getAsJsonArray();
+//                nextToken = rootObject.get("nextToken").getAsJsonObject().getAsLong();
+//
+//                listArray.forEach(processInstanceIds::add);
+//            }
+//
+//            List<Map<String, Object>> list = new ArrayList<>();
+//            if (processInstanceIds.size() > 0) {
+//                processInstanceIds.forEach(processInstanceId -> {
+//                    Map<String, Object> map = new HashMap<>();
+//                    String processInstances = dingTalkOAImpl.getProcessInstances(processInstanceId.toString(), accessToken);
+//                    Map<String, Object> processInstanceMap = parseGoOutJson(processInstances);
+//                    list.add(map);
+//                });
+//            }
+//
+//        }
+//
+//        //然后获取氚云中的OA数据
+//        String SchemaCode = "D000867sm05feb5250b9c4d9b91cd489140ff5ef9";
+//        List<Map<String, Object>> H3yunQueryList = new ArrayList<>();
+//        //第0条开始,查询500条,如果循环则需要page = page +pageSize
+//        int page = 0;
+//        int pageSize = 500;
+//
+//        while (true) {
+//            Map<String, Object> filter = new HashMap<>();
+//
+//            filter.put("FromRowNum", page*pageSize);
+//            filter.put("ToRowNum", (page+1) * pageSize);
+//            filter.put("RequireCount", false);
+//            filter.put("SortByCollection", new ArrayList<>());
+//            filter.put("ReturnItem", new ArrayList<>());
+//
+//            Map<String, Object> matcher = new HashMap();
+//            matcher.put("Type", "And");
+//            filter.put("matcher", matcher);
+//
+//            Map resultMap = h3yunClientImpl.LoadBizObjects(SchemaCode, filter);
+//
+//            Boolean successful = (Boolean) resultMap.get("Successful");
+//
+//            if (successful == null || !successful) {
+//                throw new RuntimeException("氚云查询失败:" + resultMap.get("ErrorMessage"));
+//            }
+//            Map returnData = (Map) resultMap.get("ReturnData");
+//
+//            if(returnData == null){
+//                break;
+//            }
+//
+//
+//            List<Map<String,Object>> bizObjectArray = (List)returnData.get("BizObjectArray");
+//            if (bizObjectArray == null || bizObjectArray.isEmpty()) {
+//                break;
+//            }
+//            H3yunQueryList.addAll(bizObjectArray);
+//            page++;
+//        }
+//
+//        //对比两边的数据
+//
+//        return null;
+//    }
+
+
+    /**
+     * 按“钉钉外出单据列表 + 氚云外出单据列表”对比同步数据。
+     *
+     * <p>实现逻辑:</p>
+     * <ol>
+     *     <li>分页查询钉钉外出审批实例 ID;</li>
+     *     <li>根据实例 ID 查询审批详情,并解析标题、外出事由、开始时间、结束时间、时长、外出说明、是否临时申请、临时外出理由;</li>
+     *     <li>分页查询氚云外出单据列表;</li>
+     *     <li>以钉钉审批实例 ID 和氚云 F0000009 字段作为对比依据;</li>
+     *     <li>氚云不存在则新增,已存在则根据 ObjectId 更新。</li>
+     * </ol>
+     *
+     * <p>注意:下面 F0000001 ~ F0000006 需要替换成你氚云外出表单里的真实字段编码。</p>
+     *
+     * @return 同步结果说明
+     */
+    public String syncGoOutProcessInstancesByCompare() {
+        Long endTime = TimeUtil.getCurrentTimestamp();
+        Long startTime = TimeUtil.minusDays(endTime, 5);
+        return syncGoOutProcessInstancesByCompare(startTime, endTime);
+    }
+
+    /**
+     * 同步最近 5 天内已通过的钉钉出差审批单。
+     *
+     * @return 同步结果说明
+     */
+    public String syncBusinessTripProcessInstances() {
+        Long endTime = TimeUtil.getCurrentTimestamp();
+        Long startTime = TimeUtil.minusDays(endTime, 5);
+        String accessToken = dingTalkOAImpl.getAccessToken();
+
+        List<Map<String, Object>> dingTalkBusinessTripList = queryDingTalkProcessInstanceList(
+                accessToken,
+                startTime,
+                endTime,
+                BUSINESS_TRIP_PROCESS_CODE,
+                this::parseBusinessTripJson
+        );
+        List<Map<String, Object>> h3yunBusinessTripList = queryH3yunGoOutList(BUSINESS_TRIP_SCHEMA_CODE);
+
+        Map<String, Map<String, Object>> h3yunDataMap = new HashMap<>();
+        for (Map<String, Object> h3yunData : h3yunBusinessTripList) {
+            String processInstanceId = getMapStringValue(h3yunData, BUSINESS_TRIP_PROCESS_INSTANCE_ID_FIELD_CODE);
+            if (processInstanceId != null && processInstanceId.trim().length() > 0) {
+                h3yunDataMap.put(processInstanceId, h3yunData);
+            }
+        }
+
+        int createCount = 0;
+        int updateCount = 0;
+        for (Map<String, Object> dingTalkData : dingTalkBusinessTripList) {
+            String processInstanceId = getMapStringValue(dingTalkData, "processInstanceId");
+            if (processInstanceId == null || processInstanceId.trim().length() == 0) {
+                continue;
+            }
+
+            Map<String, Object> h3yunBizObject = buildBusinessTripBizObject(dingTalkData, processInstanceId);
+            Map<String, Object> existingData = h3yunDataMap.get(processInstanceId);
+            if (existingData == null) {
+                Map result = h3yunClientImpl.CreateBizObjects(
+                        BUSINESS_TRIP_SCHEMA_CODE,
+                        Collections.singletonList(h3yunBizObject)
+                );
+                assertH3yunOperationSuccessful(result, "新增氚云出差记录");
+                createCount++;
+            } else {
+                String objectId = getMapStringValue(existingData, "ObjectId");
+                if (objectId == null || objectId.trim().length() == 0) {
+                    throw new RuntimeException("氚云出差记录缺少 ObjectId,钉钉流程ID=" + processInstanceId);
+                }
+                Map result = h3yunClientImpl.UpdateBizObject(BUSINESS_TRIP_SCHEMA_CODE, objectId, h3yunBizObject);
+                assertH3yunOperationSuccessful(result, "更新氚云出差记录");
+                updateCount++;
+            }
+        }
+
+        return "同步完成,钉钉查询到" + dingTalkBusinessTripList.size()
+                + "条,氚云查询到" + h3yunBusinessTripList.size()
+                + "条,新增" + createCount
+                + "条,更新" + updateCount + "条";
+    }
+
+    /**
+     * 同步最近 5 天内的钉钉 OA 外出审批实例到氚云。
+     *
+     * <p>查询时间范围固定为当前时间向前 5 天。</p>
+     *
+     * @return 同步结果说明
+     */
+    public String syncAllGoOutProcessInstancesByCompare() {
+        Long finalEndTime = TimeUtil.getCurrentTimestamp();
+        Long firstStartTime = TimeUtil.minusDays(finalEndTime, 5);
+
+        StringBuilder resultBuilder = new StringBuilder();
+        int chunkIndex = 1;
+        Long currentStartTime = firstStartTime;
+
+        while (currentStartTime < finalEndTime) {
+            Long currentEndTime = TimeUtil.plusDays(currentStartTime, 30);
+            if (currentEndTime > finalEndTime) {
+                currentEndTime = finalEndTime;
+            }
+
+            if (currentEndTime <= currentStartTime) {
+                break;
+            }
+
+            String result = syncGoOutProcessInstancesByCompare(currentStartTime, currentEndTime);
+            resultBuilder.append("第")
+                    .append(chunkIndex)
+                    .append("段:")
+                    .append(TimeUtil.toDateTime(currentStartTime))
+                    .append(" ~ ")
+                    .append(TimeUtil.toDateTime(currentEndTime))
+                    .append(",")
+                    .append(result)
+                    .append(System.lineSeparator());
+
+            currentStartTime = currentEndTime + 1;
+            chunkIndex++;
+        }
+
+        return resultBuilder.toString();
+    }
+
+    /**
+     * 按指定时间范围同步钉钉 OA 外出审批实例到氚云。
+     *
+     * @param startTime 查询开始时间,毫秒级时间戳
+     * @param endTime 查询结束时间,毫秒级时间戳
+     * @return 同步结果说明
+     */
+    private String syncGoOutProcessInstancesByCompare(Long startTime, Long endTime) {
+        String schemaCode = "D000867sm05feb5250b9c4d9b91cd489140ff5ef9";
+
+        String nameFieldCode = "Name";
+        String goOutReasonFieldCode = "F0000001";
+        String startTimeFieldCode = "F0000002";
+        String endTimeFieldCode = "F0000003";
+        String durationFieldCode = "F0000004";
+        String goOutDescriptionFieldCode = "F0000005";
+        String temporaryApplyFieldCode = "F0000006";
+        String temporaryReasonFieldCode = "F0000007";
+        String dingTalkProcessInstanceIdFieldCode = "F0000009";
+        String titleFieldCode = "F0000020";
+        String submitterFieldCode = "F0000021";
+
+        String accessToken = dingTalkOAImpl.getAccessToken();
+
+        List<Map<String, Object>> dingTalkGoOutList = queryDingTalkGoOutList(accessToken, startTime, endTime);
+        List<Map<String, Object>> h3yunGoOutList = queryH3yunGoOutList(schemaCode);
+
+        Map<String, Map<String, Object>> h3yunDataMap = new HashMap<>();
+        for (Map<String, Object> h3yunData : h3yunGoOutList) {
+            String dingTalkProcessInstanceId = getMapStringValue(h3yunData, dingTalkProcessInstanceIdFieldCode);
+            if (dingTalkProcessInstanceId != null && dingTalkProcessInstanceId.trim().length() > 0) {
+                h3yunDataMap.put(dingTalkProcessInstanceId, h3yunData);
+            }
+        }
+
+        int createCount = 0;
+        int updateCount = 0;
+
+        for (Map<String, Object> dingTalkData : dingTalkGoOutList) {
+            String processInstanceId = getMapStringValue(dingTalkData, "processInstanceId");
+            if (processInstanceId == null || processInstanceId.trim().length() == 0) {
+                continue;
+            }
+
+            String title = getMapStringValue(dingTalkData, "title");
+            String submitter = getStringBeforeSubmit(title);
+
+            Map<String, Object> h3yunBizObject = new HashMap<>();
+            h3yunBizObject.put(nameFieldCode, title);
+            h3yunBizObject.put(titleFieldCode, title);
+            h3yunBizObject.put(submitterFieldCode, submitter);
+            h3yunBizObject.put(dingTalkProcessInstanceIdFieldCode, processInstanceId);
+            h3yunBizObject.put(goOutReasonFieldCode, dingTalkData.get("外出事由"));
+            h3yunBizObject.put(startTimeFieldCode, dingTalkData.get("开始时间"));
+            h3yunBizObject.put(endTimeFieldCode, dingTalkData.get("结束时间"));
+            h3yunBizObject.put(durationFieldCode, dingTalkData.get("时长"));
+            h3yunBizObject.put(goOutDescriptionFieldCode, dingTalkData.get("外出说明"));
+            h3yunBizObject.put(temporaryApplyFieldCode, dingTalkData.get("是否临时申请"));
+
+            if ("临时外出".equals(dingTalkData.get("是否临时申请"))) {
+                h3yunBizObject.put(temporaryReasonFieldCode, dingTalkData.get("临时外出理由"));
+            }
+
+            Map<String, Object> existH3yunData = h3yunDataMap.get(processInstanceId);
+            if (existH3yunData == null) {
+                Map<String, String> createMap = new HashMap<>();
+                for (Map.Entry<String, Object> entry : h3yunBizObject.entrySet()) {
+                    createMap.put(entry.getKey(), entry.getValue() == null ? null : entry.getValue().toString());
+                }
+                h3yunClientImpl.CreateBizObject(schemaCode, createMap);
+                createCount++;
+            } else {
+                String objectId = getMapStringValue(existH3yunData, "ObjectId");
+                if (objectId != null && objectId.trim().length() > 0) {
+                    h3yunClientImpl.UpdateBizObject(schemaCode, objectId, h3yunBizObject);
+                    updateCount++;
+                }
+            }
+        }
+
+        return "同步完成,钉钉查询到" + dingTalkGoOutList.size()
+                + "条,氚云查询到" + h3yunGoOutList.size()
+                + "条,新增" + createCount
+                + "条,更新" + updateCount + "条";
+    }
+
+    /**
+     * 分页查询钉钉外出审批详情列表。
+     *
+     * @param accessToken 钉钉 accessToken
+     * @param startTime 查询开始时间,毫秒级时间戳
+     * @param endTime 查询结束时间,毫秒级时间戳
+     * @return 解析后的钉钉外出审批详情列表
+     */
+    private List<Map<String, Object>> queryDingTalkGoOutList(String accessToken, Long startTime, Long endTime) {
+        return queryDingTalkProcessInstanceList(
+                accessToken,
+                startTime,
+                endTime,
+                ddConfig.getWcProcessCode(),
+                this::parseGoOutJson
+        );
+    }
+
+    /**
+     * 分页查询指定审批模板下已完成的实例,并按实例 ID 获取审批详情。
+     */
+    private List<Map<String, Object>> queryDingTalkProcessInstanceList(
+            String accessToken,
+            Long startTime,
+            Long endTime,
+            String processCode,
+            Function<String, Map<String, Object>> detailParser) {
+        if (startTime == null || endTime == null) {
+            throw new RuntimeException("钉钉审批实例ID查询失败:startTime 或 endTime 不能为空");
+        }
+        if (endTime <= startTime) {
+            throw new RuntimeException("钉钉审批实例ID查询失败:结束时间不能小于或等于开始时间,startTime="
+                    + TimeUtil.toDateTime(startTime) + ",endTime=" + TimeUtil.toDateTime(endTime));
+        }
+        if (endTime - startTime > 120L * 24 * 60 * 60 * 1000) {
+            throw new RuntimeException("钉钉审批实例ID查询失败:查询时间范围不能超过120天,startTime="
+                    + TimeUtil.toDateTime(startTime) + ",endTime=" + TimeUtil.toDateTime(endTime));
+        }
+        Long earliestAllowedStartTime = TimeUtil.minusDays(TimeUtil.getCurrentTimestamp(), 365);
+        Long allowedTimeOffset = 60L * 1000;
+        if (startTime + allowedTimeOffset < earliestAllowedStartTime) {
+            throw new RuntimeException("钉钉审批实例ID查询失败:普通接口限制 startTime 距当前时间不能超过365天,startTime="
+                    + TimeUtil.toDateTime(startTime) + ",最早允许时间=" + TimeUtil.toDateTime(earliestAllowedStartTime));
+        }
+        if (processCode == null || processCode.trim().length() == 0) {
+            throw new RuntimeException("钉钉审批实例ID查询失败:processCode 不能为空");
+        }
+        if (detailParser == null) {
+            throw new RuntimeException("钉钉审批实例ID查询失败:详情解析器不能为空");
+        }
+
+        List<Map<String, Object>> dingTalkGoOutList = new ArrayList<>();
+        Set<String> processedInstanceIds = new HashSet<>();
+
+        String nextToken = "0";
+        log.info("钉钉审批实例ID查询范围:{} ~ {},processCode={}",
+                TimeUtil.toDateTime(startTime), TimeUtil.toDateTime(endTime), processCode);
+
+        while (true) {
+            Map<String, Object> ddParams = new HashMap<>();
+            ddParams.put("processCode", processCode);
+            ddParams.put("startTime", startTime);
+            ddParams.put("endTime", endTime);
+            ddParams.put("nextToken", nextToken);
+            ddParams.put("maxResults", 20L);
+            ddParams.put("statuses", Arrays.asList("COMPLETED"));
+
+            String instanceIdsResult = dingTalkOAImpl.getInstanceIds(ddParams, accessToken);
+
+            JsonParser jsonParser = new JsonParser();
+            JsonObject rootObject = jsonParser.parse(instanceIdsResult).getAsJsonObject();
+
+            boolean success = rootObject.has("success")
+                    && !rootObject.get("success").isJsonNull()
+                    && rootObject.get("success").getAsBoolean();
+
+            if (!success) {
+                throw new RuntimeException("钉钉审批实例ID查询失败:" + instanceIdsResult);
+            }
+
+            JsonObject resultObject = rootObject.getAsJsonObject("result");
+            if (resultObject == null) {
+                break;
+            }
+
+            JsonArray listArray = resultObject.getAsJsonArray("list");
+            if (listArray != null) {
+                for (JsonElement item : listArray) {
+                    if (item == null || item.isJsonNull()) {
+                        continue;
+                    }
+
+                    String processInstanceId = item.getAsString();
+                    if (processInstanceId == null || processInstanceId.trim().length() == 0) {
+                        continue;
+                    }
+                    if (!processedInstanceIds.add(processInstanceId)) {
+                        log.warn("钉钉审批实例 ID 重复,跳过重复同步:{}", processInstanceId);
+                        continue;
+                    }
+                    String processInstanceDetail = dingTalkOAImpl.getProcessInstances(processInstanceId, accessToken);
+                    Map<String, Object> processMap = detailParser.apply(processInstanceDetail);
+                    if (processMap == null) {
+                        throw new RuntimeException("钉钉审批详情解析失败,processInstanceId=" + processInstanceId);
+                    }
+                    // 审批实例 ID 列表接口返回的值才是后续查询与幂等写入的唯一依据。
+                    processMap.put("processInstanceId", processInstanceId);
+                    dingTalkGoOutList.add(processMap);
+                }
+            }
+
+            if (!resultObject.has("nextToken") || resultObject.get("nextToken").isJsonNull()) {
+                break;
+            }
+
+            String newNextToken = resultObject.get("nextToken").getAsString();
+            if (newNextToken == null
+                    || newNextToken.trim().length() == 0
+                    || "0".equals(newNextToken)
+                    || nextToken.equals(newNextToken)) {
+                break;
+            }
+
+            nextToken = newNextToken;
+        }
+
+        return dingTalkGoOutList;
+    }
+
+    /**
+     * 分页查询氚云外出单据列表。
+     *
+     * @param schemaCode 氚云表单编码
+     * @return 氚云外出单据列表
+     */
+    private List<Map<String, Object>> queryH3yunGoOutList(String schemaCode) {
+        List<Map<String, Object>> h3yunQueryList = new ArrayList<>();
+
+        int page = 0;
+        int pageSize = 500;
+
+        while (true) {
+            Map<String, Object> filter = new HashMap<>();
+            filter.put("FromRowNum", page * pageSize);
+            filter.put("ToRowNum", (page + 1) * pageSize);
+            filter.put("RequireCount", false);
+            filter.put("SortByCollection", new ArrayList<>());
+            filter.put("ReturnItem", new ArrayList<>());
+
+            Map<String, Object> matcher = new HashMap<>();
+            matcher.put("Type", "And");
+            matcher.put("Matchers", new ArrayList<>());
+            filter.put("Matcher", matcher);
+
+            Map resultMap = h3yunClientImpl.LoadBizObjects(schemaCode, filter);
+
+            Boolean successful = (Boolean) resultMap.get("Successful");
+            if (successful == null || !successful) {
+                throw new RuntimeException("氚云查询失败:" + resultMap.get("ErrorMessage"));
+            }
+
+            Map returnData = (Map) resultMap.get("ReturnData");
+            if (returnData == null) {
+                break;
+            }
+
+            List<Map<String, Object>> bizObjectArray = (List<Map<String, Object>>) returnData.get("BizObjectArray");
+            if (bizObjectArray == null || bizObjectArray.isEmpty()) {
+                break;
+            }
+
+            h3yunQueryList.addAll(bizObjectArray);
+
+            if (bizObjectArray.size() < pageSize) {
+                break;
+            }
+
+            page++;
+        }
+
+        return h3yunQueryList;
+    }
+
+    /**
+     * 从 Map 中安全获取字符串值。
+     *
+     * @param map 数据 Map
+     * @param key 字段 key
+     * @return 字符串值
+     */
+    private String getMapStringValue(Map<String, Object> map, String key) {
+        if (map == null || key == null) {
+            return null;
+        }
+        Object value = map.get(key);
+        return value == null ? null : value.toString();
+    }
+
+    /**
+     * 构造氚云查询条件中的单个字段匹配项。
+     *
+     * @param type 匹配类型,字段条件通常为 Item
+     * @param name 氚云字段编码
+     * @param operator 查询操作符,例如 0 表示大于、2 表示等于
+     * @param value 查询条件值
+     * @return 单个字段匹配项 Map
+     */
+    private Map<String, Object> buildItemMatchers(String type,String name, Integer operator, Object value) {
+        Map<String, Object> item = new HashMap<>();
+        item.put("Type", type);
+        item.put("Name", name);
+        item.put("Operator", operator);
+        item.put("Value", value);
+        return item;
+    }
+
+    /**
+     * 构造氚云查询条件分组。
+     *
+     * <p>分组类型通常为 And 或 Or,内部通过 Matchers 存放具体字段匹配项。</p>
+     *
+     * @param type 分组类型,例如 And 或 Or
+     * @param itemMatcher 单个字段匹配项
+     * @return 查询条件分组 Map
+     */
+    private Map<String, Object> buildMatcherGroup(String type, Map<String, Object> itemMatcher) {
+        Map<String, Object> group = new HashMap<>();
+        group.put("Type", type);
+
+        List<Map<String, Object>> matchers = new ArrayList<>();
+        matchers.add(itemMatcher);
+
+        group.put("Matchers", matchers);
+        return group;
+    }
+
+    private Map<String, Object> buildBusinessTripBizObject(
+            Map<String, Object> dingTalkData,
+            String processInstanceId) {
+        Map<String, Object> h3yunBizObject = new HashMap<>();
+        h3yunBizObject.put("Name", getMapStringValue(dingTalkData, "title"));
+        h3yunBizObject.put(BUSINESS_TRIP_PROCESS_INSTANCE_ID_FIELD_CODE, processInstanceId);
+        h3yunBizObject.put(BUSINESS_TRIP_REASON_FIELD_CODE, dingTalkData.get("出差事由"));
+        h3yunBizObject.put(BUSINESS_TRIP_DAYS_FIELD_CODE, dingTalkData.get("出差天数"));
+        h3yunBizObject.put(BUSINESS_TRIP_TEMPORARY_FIELD_CODE, dingTalkData.get("是否临时申请"));
+        h3yunBizObject.put(BUSINESS_TRIP_REMARK_FIELD_CODE, dingTalkData.get("出差备注"));
+        h3yunBizObject.put(BUSINESS_TRIP_COST_CENTER_FIELD_CODE, dingTalkData.get("成本中心"));
+        h3yunBizObject.put(BUSINESS_TRIP_INVOICE_TITLE_FIELD_CODE, dingTalkData.get("发票抬头"));
+        h3yunBizObject.put(BUSINESS_TRIP_COMPANION_FIELD_CODE, dingTalkData.get("同行人"));
+        h3yunBizObject.put(BUSINESS_TRIP_DEPARTMENT_FIELD_CODE, dingTalkData.get("部门"));
+        h3yunBizObject.put(BUSINESS_TRIP_APPLICANT_FIELD_CODE, dingTalkData.get("申请人"));
+        h3yunBizObject.put(BUSINESS_TRIP_APPLY_DATE_FIELD_CODE, dingTalkData.get("申请日期"));
+
+        String itineraryValue = getMapStringValue(dingTalkData, "行程");
+        if (itineraryValue == null) {
+            itineraryValue = getMapStringValue(dingTalkData, "行程数据");
+        }
+        if (itineraryValue != null && itineraryValue.trim().length() > 0) {
+            h3yunBizObject.put(BUSINESS_TRIP_ITINERARY_FIELD_CODE, parseBusinessTripItineraries(itineraryValue));
+        }
+
+        return h3yunBizObject;
+    }
+
+    /**
+     * 解析钉钉出差审批详情中的主表字段和行程原始值。
+     */
+    public Map<String, Object> parseBusinessTripJson(String json) {
+        JsonObject rootObject = new JsonParser().parse(json).getAsJsonObject();
+        JsonObject resultObject = rootObject.getAsJsonObject("result");
+        if (resultObject == null) {
+            throw new RuntimeException("钉钉出差审批详情缺少 result");
+        }
+
+        Map<String, Object> resultMap = new HashMap<>();
+        String processInstanceId = getStringValue(resultObject, "processInstanceId");
+        if (processInstanceId == null) {
+            processInstanceId = getStringValue(resultObject, "procInstId");
+        }
+        if (processInstanceId == null) {
+            processInstanceId = getStringValue(resultObject, "id");
+        }
+        if (processInstanceId == null) {
+            processInstanceId = getStringValue(resultObject, "businessId");
+        }
+        resultMap.put("processInstanceId", processInstanceId);
+        String title = getStringValue(resultObject, "title");
+        resultMap.put("title", title);
+
+        String applicant = getStringValue(resultObject, "originatorUserName");
+        if (applicant == null || applicant.trim().length() == 0) {
+            applicant = getStringBeforeSubmit(title);
+        }
+        resultMap.put("申请人", applicant);
+        resultMap.put("申请日期", formatBusinessTripApplyDate(getStringValue(resultObject, "createTime")));
+
+        JsonArray formComponentValues = resultObject.getAsJsonArray("formComponentValues");
+        if (formComponentValues == null) {
+            throw new RuntimeException("钉钉出差审批详情缺少 formComponentValues");
+        }
+
+        for (JsonElement element : formComponentValues) {
+            if (element == null || element.isJsonNull() || !element.isJsonObject()) {
+                continue;
+            }
+            JsonObject item = element.getAsJsonObject();
+            String name = getStringValue(item, "name");
+            String value = getStringValue(item, "value");
+            if (name == null || value == null) {
+                continue;
+            }
+            if ("行程".equals(name) || "行程数据".equals(name)) {
+                resultMap.put(name, value);
+            } else {
+                resultMap.put(name, formatDingTalkFormValue(value));
+            }
+        }
+
+        return resultMap;
+    }
+
+    private String formatBusinessTripApplyDate(String createTime) {
+        if (createTime == null || createTime.trim().length() == 0) {
+            return null;
+        }
+
+        String value = createTime.trim();
+        try {
+            return TimeUtil.toDateTime(Long.parseLong(value)).substring(0, 10);
+        } catch (NumberFormatException e) {
+            return value.length() >= 10 ? value.substring(0, 10) : value;
+        }
+    }
+
+    List<Map<String, Object>> parseBusinessTripItineraries(String itineraryValue) {
+        JsonElement itineraryElement;
+        try {
+            itineraryElement = new JsonParser().parse(itineraryValue);
+        } catch (Exception e) {
+            throw new RuntimeException("钉钉出差行程数据不是合法 JSON:" + itineraryValue, e);
+        }
+        if (!itineraryElement.isJsonArray()) {
+            throw new RuntimeException("钉钉出差行程数据不是数组:" + itineraryValue);
+        }
+
+        List<Map<String, Object>> itineraryRows = new ArrayList<>();
+        JsonArray sourceRows = itineraryElement.getAsJsonArray();
+        if (isNameValueArray(sourceRows)) {
+            Map<String, Object> targetRow = new HashMap<>();
+            for (JsonElement element : sourceRows) {
+                JsonObject field = element.getAsJsonObject();
+                putItineraryValue(targetRow, getStringValue(field, "name"), getStringValue(field, "value"));
+            }
+            addItineraryRow(itineraryRows, targetRow);
+            return itineraryRows;
+        }
+
+        for (JsonElement element : sourceRows) {
+            if (element == null || element.isJsonNull() || !element.isJsonObject()) {
+                continue;
+            }
+
+            JsonObject sourceRow = element.getAsJsonObject();
+            Map<String, Object> targetRow = new HashMap<>();
+            JsonArray rowValues = sourceRow.getAsJsonArray("rowValue");
+            if (rowValues != null) {
+                for (JsonElement rowValue : rowValues) {
+                    if (rowValue == null || rowValue.isJsonNull() || !rowValue.isJsonObject()) {
+                        continue;
+                    }
+                    JsonObject field = rowValue.getAsJsonObject();
+                    putItineraryValue(targetRow, getStringValue(field, "label"), getStringValue(field, "value"));
+                }
+                addItineraryRow(itineraryRows, targetRow);
+                continue;
+            }
+
+            putItineraryValue(targetRow, "交通工具", getStringValue(sourceRow, "交通工具"));
+            putItineraryValue(targetRow, "单程往返", getStringValue(sourceRow, "单程往返"));
+            putItineraryValue(targetRow, "出发城市", getStringValue(sourceRow, "出发城市"));
+            putItineraryValue(targetRow, "出发地", getStringValue(sourceRow, "出发地"));
+            putItineraryValue(targetRow, "目的城市", getStringValue(sourceRow, "目的城市"));
+            putItineraryValue(targetRow, "目的地", getStringValue(sourceRow, "目的地"));
+            putItineraryValue(targetRow, "开始时间", getStringValue(sourceRow, "开始时间"));
+            putItineraryValue(targetRow, "结束时间", getStringValue(sourceRow, "结束时间"));
+            putItineraryValue(targetRow, "时长", getStringValue(sourceRow, "时长"));
+            addItineraryRow(itineraryRows, targetRow);
+        }
+        return itineraryRows;
+    }
+
+    private boolean isNameValueArray(JsonArray sourceRows) {
+        if (sourceRows.size() == 0) {
+            return false;
+        }
+        for (JsonElement element : sourceRows) {
+            if (element == null || element.isJsonNull() || !element.isJsonObject()) {
+                return false;
+            }
+            JsonObject field = element.getAsJsonObject();
+            if (!field.has("name") || !field.has("value")) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private void putItineraryValue(Map<String, Object> targetRow, String dingTalkFieldName, String value) {
+        if (dingTalkFieldName == null || value == null) {
+            return;
+        }
+
+        String h3yunFieldCode;
+        if ("交通工具".equals(dingTalkFieldName)) {
+            h3yunFieldCode = "F0000005";
+        } else if ("单程往返".equals(dingTalkFieldName)) {
+            h3yunFieldCode = "F0000006";
+        } else if ("出发城市".equals(dingTalkFieldName) || "出发地".equals(dingTalkFieldName)) {
+            h3yunFieldCode = "F0000007";
+        } else if ("目的城市".equals(dingTalkFieldName) || "目的地".equals(dingTalkFieldName)) {
+            h3yunFieldCode = "F0000008";
+        } else if ("开始时间".equals(dingTalkFieldName)) {
+            h3yunFieldCode = "F0000009";
+        } else if ("结束时间".equals(dingTalkFieldName)) {
+            h3yunFieldCode = "F0000010";
+        } else if ("时长".equals(dingTalkFieldName)) {
+            h3yunFieldCode = "F0000011";
+        } else {
+            return;
+        }
+        targetRow.put(h3yunFieldCode, formatDingTalkFormValue(value));
+    }
+
+    private void addItineraryRow(List<Map<String, Object>> itineraryRows, Map<String, Object> targetRow) {
+        if (targetRow.isEmpty()) {
+            throw new RuntimeException("钉钉出差行程行未匹配到可同步字段");
+        }
+        itineraryRows.add(targetRow);
+    }
+
+    private void assertH3yunOperationSuccessful(Map result, String action) {
+        if (result == null || !(result.get("Successful") instanceof Boolean)
+                || !((Boolean) result.get("Successful"))) {
+            Object errorMessage = result == null ? null : result.get("ErrorMessage");
+            throw new RuntimeException(action + "失败:" + errorMessage);
+        }
+    }
+
+    public Map<String, Object> parseGoOutJson(String json) {
+        Map<String, Object> resultMap = new HashMap<>();
+
+        JsonParser jsonParser = new JsonParser();
+        JsonObject rootObject = jsonParser.parse(json).getAsJsonObject();
+
+        JsonObject resultObject = rootObject.getAsJsonObject("result");
+
+        // 1. 获取钉钉详情中的唯一 ID 和 title
+        String processInstanceId = getStringValue(resultObject, "processInstanceId");
+        if (processInstanceId == null) {
+            processInstanceId = getStringValue(resultObject, "procInstId");
+        }
+        if (processInstanceId == null) {
+            processInstanceId = getStringValue(resultObject, "id");
+        }
+        if (processInstanceId == null) {
+            processInstanceId = getStringValue(resultObject, "businessId");
+        }
+        resultMap.put("processInstanceId", processInstanceId);
+
+        String title = getStringValue(resultObject, "title");
+        resultMap.put("title", title);
+
+        // 2. 获取 formComponentValues
+        JsonArray formComponentValues = resultObject.getAsJsonArray("formComponentValues");
+
+        String goOutReason = null;
+        String startTime = null;
+        String endTime = null;
+        String duration = null;
+        String goOutDescription = null;
+        String temporaryApply = null;
+        String temporaryReason = null;
+
+        for (JsonElement element : formComponentValues) {
+            JsonObject item = element.getAsJsonObject();
+
+            String name = getStringValue(item, "name");
+            String value = formatDingTalkFormValue(getStringValue(item, "value"));
+
+            if ("外出事由".equals(name)) {
+                goOutReason = value;
+            } else if ("开始时间".equals(name)) {
+                startTime = value;
+            } else if ("结束时间".equals(name)) {
+                endTime = value;
+            } else if ("时长".equals(name)) {
+                duration = value;
+            } else if ("外出说明".equals(name)) {
+                goOutDescription = value;
+            } else if ("是否临时申请".equals(name)) {
+                temporaryApply = value;
+            } else if ("临时外出理由".equals(name)) {
+                temporaryReason = value;
+            }
+        }
+
+        resultMap.put("外出事由", goOutReason);
+        resultMap.put("开始时间", startTime);
+        resultMap.put("结束时间", endTime);
+        resultMap.put("时长", duration);
+        resultMap.put("外出说明", goOutDescription);
+        resultMap.put("是否临时申请", temporaryApply);
+
+        // 3. 如果是临时申请,才放入临时外出理由
+        if ("临时外出".equals(temporaryApply)) {
+            resultMap.put("临时外出理由", temporaryReason);
+        }
+
+        return resultMap;
+    }
+
+    /**
+     * 格式化钉钉表单组件值。
+     *
+     * <p>钉钉多选、单选等组件的 value 可能返回 JSON 数组字符串,例如 ["CRM客户拜访"]。
+     * 写入氚云普通文本字段时,需要转换为 CRM客户拜访;如果有多个选项,则使用英文逗号拼接。</p>
+     *
+     * @param value 钉钉表单组件原始 value
+     * @return 格式化后的文本值
+     */
+    private String formatDingTalkFormValue(String value) {
+        if (value == null) {
+            return null;
+        }
+
+        String trimValue = value.trim();
+        if (trimValue.length() == 0) {
+            return value;
+        }
+
+        if (!trimValue.startsWith("[") || !trimValue.endsWith("]")) {
+            return value;
+        }
+
+        try {
+            JsonElement jsonElement = new JsonParser().parse(trimValue);
+            if (!jsonElement.isJsonArray()) {
+                return value;
+            }
+
+            JsonArray jsonArray = jsonElement.getAsJsonArray();
+            List<String> valueList = new ArrayList<>();
+            for (JsonElement element : jsonArray) {
+                if (element == null || element.isJsonNull()) {
+                    continue;
+                }
+                valueList.add(element.getAsString());
+            }
+            return String.join(",", valueList);
+        } catch (Exception e) {
+            return value;
+        }
+    }
+
+    /**
+     * 获取 title 中“提交”前面的字符串。
+     *
+     * <p>例如:XX提交的外出申请,返回 XX。</p>
+     *
+     * @param title 钉钉审批实例标题
+     * @return “提交”前的字符串;如果 title 为空或不包含“提交”,返回 null
+     */
+    private String getStringBeforeSubmit(String title) {
+        if (title == null) {
+            return null;
+        }
+
+        int submitIndex = title.indexOf("提交");
+        if (submitIndex <= 0) {
+            return null;
+        }
+
+        String value = title.substring(0, submitIndex).trim();
+        return value.length() == 0 ? null : value;
+    }
+
+    private String getStringValue(JsonObject jsonObject, String key) {
+        if (jsonObject == null) {
+            return null;
+        }
+
+        if (!jsonObject.has(key)) {
+            return null;
+        }
+
+        if (jsonObject.get(key).isJsonNull()) {
+            return null;
+        }
+
+        return jsonObject.get(key).getAsString();
+    }
+
+
+
+}

+ 275 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/service/impl/h3yunClientImpl.java

@@ -0,0 +1,275 @@
+package com.example.integratedingtalkoa.service.impl;
+
+import com.example.integratedingtalkoa.config.h3yunConfig;
+import com.example.integratedingtalkoa.service.h3yunClient;
+import com.example.integratedingtalkoa.util.HttpRequestUtil;
+import com.google.gson.Gson;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 氚云 OpenApi 客户端实现类。
+ *
+ * <p>负责封装氚云单条创建、单条查询、批量查询、批量创建、单条更新等接口调用,
+ * 统一组装请求头、请求体并解析氚云接口响应。</p>
+ */
+@Service
+public class h3yunClientImpl implements h3yunClient {
+
+    @Autowired
+    h3yunConfig h3yunConfig;
+
+
+    /**
+     * 调用氚云接口创建单条业务对象。
+     *
+     * <p>该方法会将业务字段 Map 序列化为 BizObject 字符串,并通过 CreateBizObject
+     * 接口提交到指定表单。</p>
+     *
+     * @param SchemaCode 氚云表单编码
+     * @param map 业务字段 Map,key 为氚云字段编码,value 为字段值
+     * @return 氚云接口响应结果 Map
+     * @throws RuntimeException 当 HTTP 请求失败或氚云接口调用异常时抛出
+     */
+    @Override
+    public Map CreateBizObject(String SchemaCode,Map<String,String> map) {
+
+        Gson gson = new Gson();
+
+        Map<String,Object> requestMap = new HashMap();
+        requestMap.put("ActionName","CreateBizObject");
+        requestMap.put("SchemaCode",SchemaCode);
+        requestMap.put("BizObject",gson.toJson(map));
+        requestMap.put("IsSubmit",true);
+
+        String JsonBody = gson.toJson(requestMap);
+
+        Map<String,String> headers = new HashMap();
+
+        headers.put("EngineCode",h3yunConfig.getEngineCode());
+        headers.put("EngineSecret",h3yunConfig.getEngineSecret());
+
+
+        String url = h3yunConfig.getUrl() + "/CreateBizObject";
+
+        try {
+            String result = HttpRequestUtil.sendPost(url, JsonBody, headers);
+            return gson.fromJson(result, Map.class);
+        } catch (Exception e) {
+            throw new RuntimeException("调用氚云CreateBizObject接口失败:" + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 根据业务对象 ID 查询单条氚云业务对象。
+     *
+     * @param SchemaCode 氚云表单编码
+     * @param bizObjectId 氚云业务对象 ID
+     * @return 氚云接口响应结果 Map
+     * @throws RuntimeException 当 HTTP 请求失败或氚云接口调用异常时抛出
+     */
+    @Override
+    public Map LoadBizObject(String SchemaCode,String bizObjectId) {
+        Gson gson = new Gson();
+
+        Map<String, Object> requestMap = new HashMap<>();
+
+        requestMap.put("ActionName", "LoadBizObject");
+        requestMap.put("SchemaCode", SchemaCode);
+        requestMap.put("BizObjectId", bizObjectId);
+
+        String jsonBody = gson.toJson(requestMap);
+
+        Map<String, String> headers = new HashMap<>();
+        headers.put("EngineCode", h3yunConfig.getEngineCode());
+        headers.put("EngineSecret", h3yunConfig.getEngineSecret());
+
+        String url = h3yunConfig.getUrl() + "/LoadBizObject";
+
+        try {
+            String result = HttpRequestUtil.sendPost(url, jsonBody, headers);
+            return gson.fromJson(result, Map.class);
+        } catch (Exception e) {
+            throw new RuntimeException("调用氚云LoadBizObject接口失败:" + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 根据过滤条件批量查询氚云业务对象。
+     *
+     * <p>氚云 LoadBizObjects 接口最大查询 500 条数据。Filter 通常需要作为 JSON 字符串
+     * 放入请求体中,其中 Matcher 用于设置 And、Or、Item 等查询条件。</p>
+     *
+     * <p>常用 Operator 说明:</p>
+     * <ul>
+     *     <li>0:大于</li>
+     *     <li>1:大于等于</li>
+     *     <li>2:等于</li>
+     *     <li>3:小于等于</li>
+     *     <li>4:小于</li>
+     *     <li>5:不等于</li>
+     *     <li>6:在某个范围内</li>
+     *     <li>7:不在某个范围内</li>
+     *     <li>8:左对象包含右对象</li>
+     *     <li>13:以指定字符串开头</li>
+     * </ul>
+     *
+     * @param schemaCode 氚云表单编码
+     * @param filter 氚云批量查询过滤条件 Map
+     * @return 氚云接口响应结果 Map
+     * @throws RuntimeException 当 HTTP 请求失败或氚云接口调用异常时抛出
+     */
+    @Override
+    public Map LoadBizObjects(String schemaCode, Map<String, Object> filter) {
+        Gson gson = new Gson();
+
+        Map<String, Object> requestMap = new HashMap<>();
+        requestMap.put("ActionName", "LoadBizObjects");
+        requestMap.put("SchemaCode", schemaCode);
+
+        // 注意:H3Yun 的 Filter 通常要求传 JSON 字符串
+        requestMap.put("Filter", gson.toJson(filter));
+
+        String jsonBody = gson.toJson(requestMap);
+
+        Map<String, String> headers = new HashMap<>();
+        headers.put("EngineCode", h3yunConfig.getEngineCode());
+        headers.put("EngineSecret", h3yunConfig.getEngineSecret());
+
+        String url = h3yunConfig.getUrl() + "/LoadBizObjects";
+
+        try {
+            String result = HttpRequestUtil.sendPost(url, jsonBody, headers);
+            return gson.fromJson(result, Map.class);
+        } catch (Exception e) {
+            throw new RuntimeException("调用氚云LoadBizObjects接口失败:" + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 批量创建氚云业务对象。
+     *
+     * <p>该方法会将每条业务字段 Map 单独序列化成 BizObject JSON 字符串,
+     * 再以 List&lt;String&gt; 的形式放入 BizObjectArray,避免将整个数组再次序列化成普通字符串。</p>
+     *
+     * @param SchemaCode 氚云表单编码
+     * @param listMap 待创建的业务对象字段集合,每个 Map 表示一条业务数据
+     * @return 氚云接口响应结果 Map
+     * @throws RuntimeException 当 HTTP 请求失败或氚云接口调用异常时抛出
+     */
+    @Override
+    public Map CreateBizObjects(String SchemaCode, List<Map<String,Object>> listMap) {
+        Gson gson = new Gson();
+
+        List<String> bizObjectArray = new ArrayList<>();
+        for (Map<String, Object> map : listMap) {
+            Map<String, Object> bizObject = new HashMap<>(map);
+            bizObjectArray.add(gson.toJson(bizObject));
+        }
+
+        Map<String, Object> requestMap = new HashMap<>();
+        requestMap.put("ActionName", "CreateBizObjects");
+        requestMap.put("SchemaCode", SchemaCode);
+        requestMap.put("BizObjectArray", bizObjectArray);
+        requestMap.put("IsSubmit", true);
+
+        String jsonBody = gson.toJson(requestMap);
+
+        Map<String,String> headers = new HashMap<String,String>();
+        headers.put("EngineCode",h3yunConfig.getEngineCode());
+        headers.put("EngineSecret",h3yunConfig.getEngineSecret());
+        System.out.println("JsonBody:" + jsonBody);
+
+        String url = h3yunConfig.getUrl() + "/CreateBizObjects";
+
+        try {
+            String result = HttpRequestUtil.sendPost(url, jsonBody, headers);
+            return gson.fromJson(result, Map.class);
+        }catch (Exception e){
+            throw new RuntimeException("调用氚云CreateBizObjects接口失败:" + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 根据业务对象 ID 更新氚云业务对象。
+     *
+     * <p>该方法会将待更新字段 Map 序列化为 BizObject 字符串,并调用 UpdateBizObject
+     * 接口更新指定业务对象。</p>
+     *
+     * @param SchemaCode 氚云表单编码
+     * @param BizObjectId 氚云业务对象 ID
+     * @param map 待更新字段 Map,key 为氚云字段编码,value 为字段值
+     * @return 氚云接口响应结果 Map
+     * @throws RuntimeException 当 HTTP 请求失败或氚云接口调用异常时抛出
+     */
+    @Override
+    public Map UpdateBizObject(String SchemaCode, String BizObjectId, Map<String, Object> map) {
+        Gson gson = new Gson();
+
+        List<String> bizObjectArray = new ArrayList<>();
+        Map<String, Object> requestMap = new HashMap<>();
+        requestMap.put("ActionName", "UpdateBizObject");
+        requestMap.put("SchemaCode", SchemaCode);
+        requestMap.put("BizObjectId", BizObjectId);
+        requestMap.put("BizObject", gson.toJson(map));
+
+        String jsonBody = gson.toJson(requestMap);
+
+        Map<String,String> headers = new HashMap<String,String>();
+        headers.put("EngineCode",h3yunConfig.getEngineCode());
+        headers.put("EngineSecret",h3yunConfig.getEngineSecret());
+        System.out.println("JsonBody:" + jsonBody);
+
+        String url = h3yunConfig.getUrl() + "/UpdateBizObject";
+
+        try {
+            String result = HttpRequestUtil.sendPost(url, jsonBody, headers);
+            return gson.fromJson(result, Map.class);
+        }catch (Exception e){
+            throw new RuntimeException("调用氚云UpdateBizObject接口失败:" + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 根据业务对象 ID 删除氚云业务对象。
+     *
+     * <p>该方法会调用 RemoveBizObject接口删除指定业务对象。</p>
+     *
+     * @param SchemaCode 氚云表单编码
+     * @param BizObjectId 氚云业务对象 ID
+     * @return 氚云接口响应结果 Map
+     * @throws RuntimeException 当 HTTP 请求失败或氚云接口调用异常时抛出
+     */
+    @Override
+    public Map RemoveBizObject(String SchemaCode, String BizObjectId) {
+        Gson gson = new Gson();
+
+        Map<String, Object> requestMap = new HashMap<>();
+        requestMap.put("ActionName", "RemoveBizObject");
+        requestMap.put("SchemaCode", SchemaCode);
+        requestMap.put("BizObjectId", BizObjectId);
+
+        String jsonBody = gson.toJson(requestMap);
+
+        Map<String,String> headers = new HashMap<>();
+        headers.put("EngineCode",h3yunConfig.getEngineCode());
+        headers.put("EngineSecret",h3yunConfig.getEngineSecret());
+        System.out.println("JsonBody:" + jsonBody);
+
+        String url = h3yunConfig.getUrl() + "/RemoveBizObject";
+
+        try {
+            String result = HttpRequestUtil.sendPost(url,jsonBody,headers);
+            return gson.fromJson(result, Map.class);
+        }catch (Exception e){
+            throw new RuntimeException("调用氚云RemoveBizObject接口失败:" + e.getMessage(), e);
+        }
+    }
+
+
+}

+ 192 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/util/HttpRequestUtil.java

@@ -0,0 +1,192 @@
+package com.example.integratedingtalkoa.util;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Map;
+
+public class HttpRequestUtil {
+
+    private static final int DEFAULT_CONNECT_TIMEOUT = 5000;
+    private static final int DEFAULT_READ_TIMEOUT = 10000;
+
+    private HttpRequestUtil() {
+    }
+
+    public static String sendGet(String url) {
+        return sendGet(url, Collections.<String, String>emptyMap(), Collections.<String, String>emptyMap());
+    }
+
+    public static String sendGet(String url, Map<String, String> params) {
+        return sendGet(url, params, Collections.<String, String>emptyMap());
+    }
+
+    public static String sendGet(String url, Map<String, String> params, Map<String, String> headers) {
+        String requestUrl = buildUrlWithParams(url, params);
+        return send(requestUrl, "GET", null, headers, null);
+    }
+
+    public static String sendPost(String url, String jsonBody) {
+        return sendPost(url, jsonBody, Collections.<String, String>emptyMap());
+    }
+
+    public static String sendPost(String url, String jsonBody, Map headers) {
+        return send(url, "POST", jsonBody, headers, "application/json;charset=UTF-8");
+    }
+
+    public static String sendPostJson(String url, String jsonBody) {
+        return sendPost(url, jsonBody, Collections.<String, String>emptyMap());
+    }
+
+    public static String sendPostJson(String url, String jsonBody, Map<String, String> headers) {
+        return send(url, "POST", jsonBody, headers, "application/json;charset=UTF-8");
+    }
+
+    public static String sendPostForm(String url, Map<String, String> formParams) {
+        return sendPostForm(url, formParams, Collections.<String, String>emptyMap());
+    }
+
+    public static String sendPostForm(String url, Map<String, String> formParams, Map<String, String> headers) {
+        String body = buildQueryString(formParams);
+        return send(url, "POST", body, headers, "application/x-www-form-urlencoded;charset=UTF-8");
+    }
+
+    private static String send(String urlStr, String method, String body, Map headers, String contentType) {
+        HttpURLConnection connection = null;
+
+        try {
+            URL url = new URL(urlStr);
+            connection = (HttpURLConnection) url.openConnection();
+            connection.setRequestMethod(method);
+            connection.setConnectTimeout(DEFAULT_CONNECT_TIMEOUT);
+            connection.setReadTimeout(DEFAULT_READ_TIMEOUT);
+            connection.setDoInput(true);
+            connection.setUseCaches(false);
+
+            if (isNotBlank(contentType)) {
+                connection.setRequestProperty("Content-Type", contentType);
+            }
+            connection.setRequestProperty("Accept", "application/json, text/plain, */*");
+
+            setHeaders(connection, headers);
+
+            if (isNotBlank(body)) {
+                connection.setDoOutput(true);
+                byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+                connection.setRequestProperty("Content-Length", String.valueOf(bytes.length));
+
+                try (OutputStream outputStream = connection.getOutputStream()) {
+                    outputStream.write(bytes);
+                    outputStream.flush();
+                }
+            }
+
+            int statusCode = connection.getResponseCode();
+            InputStream inputStream = statusCode >= 200 && statusCode < 300
+                    ? connection.getInputStream()
+                    : connection.getErrorStream();
+
+            String responseBody = readResponse(inputStream);
+
+            if (statusCode < 200 || statusCode >= 300) {
+                throw new RuntimeException("HTTP request failed, statusCode=" + statusCode + ", response=" + responseBody);
+            }
+
+            return responseBody;
+        } catch (IOException e) {
+            throw new RuntimeException("HTTP request error: " + e.getMessage(), e);
+        } finally {
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    private static void setHeaders(HttpURLConnection connection, Map headers) {
+        if (headers == null || headers.isEmpty()) {
+            return;
+        }
+
+        for (Object item : headers.entrySet()) {
+            Map.Entry entry = (Map.Entry) item;
+            Object key = entry.getKey();
+            Object value = entry.getValue();
+
+            if (key == null || value == null) {
+                continue;
+            }
+
+            connection.setRequestProperty(String.valueOf(key), String.valueOf(value));
+        }
+    }
+
+    private static String buildUrlWithParams(String url, Map<String, String> params) {
+        if (params == null || params.isEmpty()) {
+            return url;
+        }
+
+        String queryString = buildQueryString(params);
+        if (queryString.length() == 0) {
+            return url;
+        }
+
+        String separator = url.contains("?") ? "&" : "?";
+        return url + separator + queryString;
+    }
+
+    private static String buildQueryString(Map<String, String> params) {
+        if (params == null || params.isEmpty()) {
+            return "";
+        }
+
+        StringBuilder builder = new StringBuilder();
+        for (Map.Entry<String, String> entry : params.entrySet()) {
+            if (entry.getKey() == null || entry.getValue() == null) {
+                continue;
+            }
+
+            if (builder.length() > 0) {
+                builder.append("&");
+            }
+
+            builder.append(urlEncode(entry.getKey()))
+                    .append("=")
+                    .append(urlEncode(entry.getValue()));
+        }
+        return builder.toString();
+    }
+
+    private static String urlEncode(String value) {
+        try {
+            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+        } catch (Exception e) {
+            throw new RuntimeException("URL encode error: " + e.getMessage(), e);
+        }
+    }
+
+    private static boolean isNotBlank(String value) {
+        return value != null && value.trim().length() > 0;
+    }
+
+    private static String readResponse(InputStream inputStream) throws IOException {
+        if (inputStream == null) {
+            return "";
+        }
+
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
+            StringBuilder builder = new StringBuilder();
+            String line;
+            while ((line = reader.readLine()) != null) {
+                builder.append(line);
+            }
+            return builder.toString();
+        }
+    }
+}

+ 29 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/util/ObjectMapUtil.java

@@ -0,0 +1,29 @@
+package com.example.integratedingtalkoa.util;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.util.Map;
+
+
+public class ObjectMapUtil {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    /**
+     * 对象转 Map
+     *
+     * @param object 任意对象
+     * @return Map<String, Object>
+     */
+    public static Map<String, Object> objectToMap(Object object) {
+        if (object == null) {
+            return null;
+        }
+
+        return OBJECT_MAPPER.convertValue(
+                object,
+                new TypeReference<Map<String, Object>>() {}
+        );
+    }
+}

+ 157 - 0
IntegrateDingTalkOA/src/main/java/com/example/integratedingtalkoa/util/TimeUtil.java

@@ -0,0 +1,157 @@
+package com.example.integratedingtalkoa.util;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 时间工具类。
+ *
+ * <p>提供当前时间、当前毫秒级时间戳、指定格式时间转时间戳、
+ * 以及按天、月、年对时间戳进行加减计算的常用方法。</p>
+ */
+public final class TimeUtil {
+
+    /**
+     * 默认时间格式:yyyy-MM-dd HH:mm:ss。
+     *
+     * <p>注意:Java 中 HH 表示 24 小时制,hh 表示 12 小时制。
+     * 为了兼容常见输入,本工具类对外说明仍按 yyyy-MM-dd hh:mm:ss 理解,
+     * 实际推荐传入 24 小时制时间,例如 2026-08-04 11:03:51。</p>
+     */
+    private static final String DEFAULT_PATTERN = "yyyy-MM-dd HH:mm:ss";
+
+    /**
+     * 默认系统时区。
+     */
+    private static final ZoneId DEFAULT_ZONE_ID = ZoneId.systemDefault();
+
+    /**
+     * 默认时间格式化器。
+     */
+    private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_PATTERN);
+
+    private TimeUtil() {
+    }
+
+    /**
+     * 获取当前时间字符串。
+     *
+     * @return 当前时间,格式为 yyyy-MM-dd HH:mm:ss
+     */
+    public static String getCurrentTime() {
+        return LocalDateTime.now().format(DEFAULT_FORMATTER);
+    }
+
+    /**
+     * 获取当前毫秒级时间戳。
+     *
+     * @return 当前毫秒级时间戳
+     */
+    public static long getCurrentTimestamp() {
+        return System.currentTimeMillis();
+    }
+
+    /**
+     * 将 yyyy-MM-dd HH:mm:ss 格式的时间字符串转换为毫秒级时间戳。
+     *
+     * @param dateTime 时间字符串,例如 2026-08-04 11:03:51
+     * @return 对应的毫秒级时间戳
+     */
+    public static long toTimestamp(String dateTime) {
+        if (dateTime == null || dateTime.trim().length() == 0) {
+            throw new IllegalArgumentException("时间字符串不能为空");
+        }
+        LocalDateTime localDateTime = LocalDateTime.parse(dateTime.trim(), DEFAULT_FORMATTER);
+        return localDateTime.atZone(DEFAULT_ZONE_ID).toInstant().toEpochMilli();
+    }
+
+    /**
+     * 将毫秒级时间戳转换为 yyyy-MM-dd HH:mm:ss 格式的时间字符串。
+     *
+     * @param timestampMillis 毫秒级时间戳
+     * @return 时间字符串
+     */
+    public static String toDateTime(long timestampMillis) {
+        return LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(timestampMillis), DEFAULT_ZONE_ID)
+                .format(DEFAULT_FORMATTER);
+    }
+
+    /**
+     * 时间戳增加指定天数。
+     *
+     * @param timestampMillis 毫秒级时间戳
+     * @param days 增加的天数
+     * @return 计算后的毫秒级时间戳
+     */
+    public static long plusDays(long timestampMillis, long days) {
+        return LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(timestampMillis), DEFAULT_ZONE_ID)
+                .plusDays(days)
+                .atZone(DEFAULT_ZONE_ID)
+                .toInstant()
+                .toEpochMilli();
+    }
+
+    /**
+     * 时间戳减少指定天数。
+     *
+     * @param timestampMillis 毫秒级时间戳
+     * @param days 减少的天数
+     * @return 计算后的毫秒级时间戳
+     */
+    public static long minusDays(long timestampMillis, long days) {
+        return plusDays(timestampMillis, -days);
+    }
+
+    /**
+     * 时间戳增加指定月数。
+     *
+     * @param timestampMillis 毫秒级时间戳
+     * @param months 增加的月数
+     * @return 计算后的毫秒级时间戳
+     */
+    public static long plusMonths(long timestampMillis, long months) {
+        return LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(timestampMillis), DEFAULT_ZONE_ID)
+                .plusMonths(months)
+                .atZone(DEFAULT_ZONE_ID)
+                .toInstant()
+                .toEpochMilli();
+    }
+
+    /**
+     * 时间戳减少指定月数。
+     *
+     * @param timestampMillis 毫秒级时间戳
+     * @param months 减少的月数
+     * @return 计算后的毫秒级时间戳
+     */
+    public static long minusMonths(long timestampMillis, long months) {
+        return plusMonths(timestampMillis, -months);
+    }
+
+    /**
+     * 时间戳增加指定年数。
+     *
+     * @param timestampMillis 毫秒级时间戳
+     * @param years 增加的年数
+     * @return 计算后的毫秒级时间戳
+     */
+    public static long plusYears(long timestampMillis, long years) {
+        return LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(timestampMillis), DEFAULT_ZONE_ID)
+                .plusYears(years)
+                .atZone(DEFAULT_ZONE_ID)
+                .toInstant()
+                .toEpochMilli();
+    }
+
+    /**
+     * 时间戳减少指定年数。
+     *
+     * @param timestampMillis 毫秒级时间戳
+     * @param years 减少的年数
+     * @return 计算后的毫秒级时间戳
+     */
+    public static long minusYears(long timestampMillis, long years) {
+        return plusYears(timestampMillis, -years);
+    }
+}

+ 12 - 0
IntegrateDingTalkOA/src/main/resources/application.yml

@@ -0,0 +1,12 @@
+server:
+  port: 80
+
+h3yun:
+  EngineCode: sbitdbdh2v4hm7h1wn9xtk4t6
+  EngineSecret: uReH7UwBOSarIaABsja89RxLisMg8eT1/35OIQjLNMJnwZVkXMUuyA==
+  url: https://www.h3yun.com/OpenApi/Invoke
+
+dingtalk:
+  appKey: dingozv6fzkpqkiupd3d
+  appSecret: bO4AA6ujXj8xgLBJI5pR7ns0vRsHCn8Ng9fTf9WF95HTOlCW0oybYpHsuxXuBPiO
+  wcProcessCode: PROC-4FDE5E93-B09E-4391-BF08-D0ACC496D659

+ 75 - 0
IntegrateDingTalkOA/src/test/java/com/example/integratedingtalkoa/IntegrateDingTalkOaApplicationTests.java

@@ -0,0 +1,75 @@
+package com.example.integratedingtalkoa;
+
+import com.example.integratedingtalkoa.service.impl.DingTalkOAImpl;
+import com.example.integratedingtalkoa.service.impl.IntegrateCYImpl;
+import com.example.integratedingtalkoa.util.TimeUtil;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+@SpringBootTest
+class IntegrateDingTalkOaApplicationTests {
+
+    private static final String BUSINESS_TRIP_PROCESS_CODE = "PROC-FF6Y696SO2-C63CMT87Q01PP2VN8V5D2-OVJZHHQI-45";
+
+    @Autowired
+    private IntegrateCYImpl integrateCYImpl;
+
+    @Autowired
+    private DingTalkOAImpl dingTalkOAImpl;
+
+    @Test
+    void contextLoads() {
+    }
+
+    /**
+     * 手动同步钉钉 OA 外出审批数据到氚云。
+     *
+     * <p>执行该测试方法会真实调用钉钉和氚云接口,并根据 F0000009 字段判断新增或更新。</p>
+     */
+    @Test
+    void syncAllDingTalkGoOutData() {
+        String result = integrateCYImpl.syncAllGoOutProcessInstancesByCompare();
+        System.out.println(result);
+
+        String businessTripResult = integrateCYImpl.syncBusinessTripProcessInstances();
+        System.out.println(businessTripResult);
+    }
+
+    /**
+     * 只读输出一条已通过出差审批的原始详情响应,用于排查行程子表结构。
+     */
+    @Test
+    void printOneBusinessTripProcessInstanceDetail() {
+        long endTime = TimeUtil.getCurrentTimestamp();
+        Map<String, Object> params = new HashMap<>();
+        params.put("processCode", BUSINESS_TRIP_PROCESS_CODE);
+        params.put("startTime", TimeUtil.minusDays(endTime, 5));
+        params.put("endTime", endTime);
+        params.put("nextToken", "0");
+        params.put("maxResults", 20L);
+        params.put("statuses", Arrays.asList("COMPLETED"));
+
+        String accessToken = dingTalkOAImpl.getAccessToken();
+        String instanceIdsResponse = dingTalkOAImpl.getInstanceIds(params, accessToken);
+        JsonObject root = new JsonParser().parse(instanceIdsResponse).getAsJsonObject();
+        JsonObject result = root.getAsJsonObject("result");
+        JsonArray instanceIds = result == null ? null : result.getAsJsonArray("list");
+        if (instanceIds == null || instanceIds.size() == 0) {
+            throw new IllegalStateException("最近 30 天内未查询到已通过的出差审批实例");
+        }
+
+        String processInstanceId = instanceIds.get(0).getAsString();
+        String processInstanceDetail = dingTalkOAImpl.getProcessInstances(processInstanceId, accessToken);
+        System.out.println("出差审批实例 ID:" + processInstanceId);
+        System.out.println("出差审批详情响应:" + processInstanceDetail);
+    }
+
+}

+ 74 - 0
IntegrateDingTalkOA/src/test/java/com/example/integratedingtalkoa/service/impl/ProcessInstanceIdParsingTest.java

@@ -0,0 +1,74 @@
+package com.example.integratedingtalkoa.service.impl;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ProcessInstanceIdParsingTest {
+
+    private static final String DETAIL_JSON = "{\"result\":{\"businessId\":\"202606301803000049958\","
+            + "\"processInstanceId\":\"PzlufiWjQGy5HGVpz-tfRw04351782813784\","
+            + "\"originatorUserName\":\"黄涛\",\"createTime\":\"2026-08-14 14:54:00\","
+            + "\"title\":\"测试提交的外出\",\"formComponentValues\":[]}}";
+
+    @Test
+    void shouldPreferProcessInstanceIdForGoOutAndBusinessTrip() {
+        IntegrateCYImpl service = new IntegrateCYImpl();
+
+        Map<String, Object> goOutData = service.parseGoOutJson(DETAIL_JSON);
+        Map<String, Object> businessTripData = service.parseBusinessTripJson(DETAIL_JSON);
+
+        assertEquals("PzlufiWjQGy5HGVpz-tfRw04351782813784", goOutData.get("processInstanceId"));
+        assertEquals("PzlufiWjQGy5HGVpz-tfRw04351782813784", businessTripData.get("processInstanceId"));
+        assertEquals("黄涛", businessTripData.get("申请人"));
+        assertEquals("2026-08-14", businessTripData.get("申请日期"));
+    }
+
+    @Test
+    void shouldUseChildFieldCodesForBusinessTripItinerary() {
+        IntegrateCYImpl service = new IntegrateCYImpl();
+        List<Map<String, Object>> itineraryRows = service.parseBusinessTripItineraries(
+                "[{\"交通工具\":\"火车\",\"单程往返\":\"往返\",\"出发城市\":\"上海\","
+                        + "\"目的城市\":\"宁波\",\"开始时间\":\"2026-08-17 上午\","
+                        + "\"结束时间\":\"2026-08-19 下午\",\"时长\":\"3\"}]"
+        );
+
+        Map<String, Object> row = itineraryRows.get(0);
+        assertEquals("火车", row.get("F0000005"));
+        assertEquals("宁波", row.get("F0000008"));
+        assertEquals("3", row.get("F0000011"));
+    }
+
+    @Test
+    void shouldParseNameValueBusinessTripItinerary() {
+        IntegrateCYImpl service = new IntegrateCYImpl();
+        List<Map<String, Object>> itineraryRows = service.parseBusinessTripItineraries(
+                "[{\"name\":\"交通工具\",\"value\":\"火车\"},{\"name\":\"出发地\",\"value\":\"上海\"},"
+                        + "{\"name\":\"目的地\",\"value\":\"宁波\"},{\"name\":\"时长\",\"value\":\"3\"}]"
+        );
+
+        Map<String, Object> row = itineraryRows.get(0);
+        assertEquals("上海", row.get("F0000007"));
+        assertEquals("宁波", row.get("F0000008"));
+    }
+
+    @Test
+    void shouldParseRowValueBusinessTripItinerary() {
+        IntegrateCYImpl service = new IntegrateCYImpl();
+        List<Map<String, Object>> itineraryRows = service.parseBusinessTripItineraries(
+                "[{\"rowValue\":[{\"label\":\"交通工具\",\"value\":\"火车\"},"
+                        + "{\"label\":\"出发城市\",\"value\":\"杭州\"},"
+                        + "{\"label\":\"目的城市\",\"value\":\"宁波\"},"
+                        + "{\"label\":\"时长\",\"value\":1}]}]"
+        );
+
+        Map<String, Object> row = itineraryRows.get(0);
+        assertEquals("火车", row.get("F0000005"));
+        assertEquals("杭州", row.get("F0000007"));
+        assertEquals("宁波", row.get("F0000008"));
+        assertEquals("1", row.get("F0000011"));
+    }
+}

File diff suppressed because it is too large
+ 179 - 0
IntegrateDingTalkOA/src/test/test.json


+ 175 - 0
mjava-jinlun/README.md

@@ -0,0 +1,175 @@
+# mjava-jinlun
+
+金伦项目的 Spring Boot 集成服务,用于在氚云/云枢、金蝶云星空和 Teambition 之间同步业务数据。项目主要承担表单回调接口、金蝶单据创建/更新、云枢表单回写、定时同步和客户对账查询。
+
+## 技术栈
+
+- Java 8
+- Spring Boot 2.2.0.RELEASE
+- Maven
+- MyBatis / MyBatis-Plus
+- MySQL Connector 8.0.32
+- 金蝶云星空 WebAPI SDK
+- 云枢 OpenAPI
+- Teambition OpenAPI
+
+## 项目结构
+
+```text
+src/main/java/com/malk/jinlun
+├── JinlunApplication.java        # 应用入口,启用定时任务
+├── config                        # 金蝶、云枢配置映射
+├── controller                    # 对外 HTTP 接口
+├── entity                        # 金蝶请求模型与通用入参模型
+├── mapper                        # MyBatis Mapper
+├── schedule                      # 定时同步任务
+└── service                       # 业务服务、云枢客户端与实现
+
+src/main/resources
+├── mapper/JinlunMapper.xml       # 客户对账 SQL
+└── logback-spring.xml            # 日志配置
+```
+
+## 核心功能
+
+### 表单触发接口
+
+这些接口通常由云枢/氚云表单动作调用,请求体通过 `objectId` 定位表单实例,再按业务规则读取表单字段、组装金蝶单据、提交审核并回写结果。
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| `GET` | `/test` | 连通性测试 |
+| `POST` | `/addCustomer` | 根据云枢客户流程 `ZLKHLC` 创建金蝶客户 `BD_Customer` |
+| `POST` | `/updateCustomer` | 根据云枢客户档案 `KHDA` 更新金蝶客户销售员 |
+| `POST` | `/addCustomerContact` | 根据云枢联系人 `JL002` 创建金蝶联系人 `BD_CommonContact`,并回写联系人编码 |
+| `POST` | `/addSaleOrder` | 根据云枢销售订单 `XSDD` 创建金蝶销售订单 `SAL_SaleOrder`,并回写订单编号 |
+| `POST` | `/createTbProject` | 根据云枢重要样品打样申请 `ZYYPDYSQ` 创建 Teambition 项目,并回写项目 ID |
+| `POST` | `/queryFromData` | 按 `schemaCode`、`propertyCode`、`propertyValue` 查询单条云枢表单数据 |
+
+示例请求:
+
+```json
+{
+  "objectId": "云枢表单实例ID"
+}
+```
+
+`/queryFromData` 请求示例:
+
+```json
+{
+  "schemaCode": "KHDA",
+  "propertyCode": "ShortText1758195840127",
+  "propertyValue": "客户编码"
+}
+```
+
+### 客户对账查询
+
+| 方法 | 路径 | 参数 | 说明 |
+| --- | --- | --- | --- |
+| `GET` | `/getCustomerDzmx` | `startDate`、`endDate`、`customerName` | 查询客户回款对账明细 |
+| `GET` | `/getCustomerWqqk` | `startDate`、`customerName` | 查询客户往期欠款 |
+
+SQL 位于 `src/main/resources/mapper/JinlunMapper.xml`,当前查询涉及销售出库单、收款单和暂估应收等云枢业务表。
+
+### 定时同步任务
+
+`JinlunTask` 由配置项 `enable.scheduling` 控制是否启用。开启后每天 00:00 起按分钟顺序执行以下任务:
+
+| 时间 | 方法 | 金蝶 FormId | 云枢 schemaCode | 说明 |
+| --- | --- | --- | --- | --- |
+| 00:00 | `syncMaterial` | `BD_MATERIAL` | `WL` | 同步物料 |
+| 00:01 | `syncSaleOut` | `SAL_OUTSTOCK` | `XSCKD` | 同步销售出库单 |
+| 00:02 | `syncReceipt` | `AR_RECEIVEBILL` | `SKD` | 同步收款单 |
+| 00:03 | `syncReceivable` | `AR_receivable` | `YSD` | 同步应收单 |
+| 00:04 | `syncSaleReturn` | `SAL_RETURNSTOCK` | `XSTHD` | 同步销售退货单 |
+| 00:05 | `syncXingneng` | `Sl_xingneng` | `XN` | 同步性能基础资料 |
+| 00:06 | `syncSaleOrder` | `SAL_SaleOrder` | `XSDD_JDTB` | 同步销售订单进度 |
+| 00:07 | `syncReceiptVerification` | `AR_MatchRecord` | `YSSKHXJL` | 同步应收收款核销记录 |
+
+定时同步采用“先按关键字段查云枢,再更新或新增”的模式,避免重复创建。
+
+## 配置项
+
+项目通过 `@ConfigurationProperties` 读取配置。请在本地或部署环境中提供以下配置,不要把真实密钥、Token、Cookie 或生产连接串提交到仓库。
+
+```yaml
+cloudpivot:
+  grant-type: xxx
+  client-id: xxx
+  client-secret: xxx
+  redirect-uri: xxx
+  operator-user-id: xxx
+
+kingdee:
+  XKDApiAcctID: xxx
+  XKDApiUserName: xxx
+  XKDApiAppID: xxx
+  XKDApiAppSec: xxx
+  XKDApiServerUrl: xxx
+
+enable:
+  scheduling: true
+```
+
+项目还依赖公共包中的 Teambition 配置 `TBConf`,实际配置键以 `com.malk.server.teambition` 所在基础包为准。
+
+## 构建与运行
+
+在项目根目录执行:
+
+```bash
+mvn clean package
+```
+
+构建产物名由 `pom.xml` 指定为:
+
+```text
+target/jinlun.jar
+```
+
+本地运行:
+
+```bash
+java -jar target/jinlun.jar
+```
+
+如需指定环境:
+
+```bash
+java -jar target/jinlun.jar --spring.profiles.active=dev
+```
+
+`pom.xml` 中当前配置了 `<skipTests>true</skipTests>`,默认 Maven 构建会跳过测试。若要执行测试,可临时覆盖:
+
+```bash
+mvn test -DskipTests=false
+```
+
+## 外部依赖
+
+运行前需要确认以下依赖可用:
+
+- `com.malk:base:1.1-SNAPSHOT` 已安装到本地 Maven 仓库或可从私服下载。
+- `com.example:k3cloud:8.0.6` 已安装到本地 Maven 仓库或可从私服下载。
+- `com.example:gson:2.8.0` 已安装到本地 Maven 仓库或可从私服下载。
+- 应用可访问金蝶云星空 WebAPI、云枢 OpenAPI、Teambition OpenAPI 和业务数据库。
+
+## 开发约束
+
+- 表单字段编码、表单编码、金蝶 FormId 和云枢 schemaCode 必须以平台实际配置为准,不要臆造或随意替换。
+- 涉及第三方凭证时,只通过配置注入,不在代码、README、测试数据或日志中硬编码。
+- 新增云枢表单调用时,优先复用 `CpClient` 的 `getCpBo`、`getCpBoList`、`createCpBo`、`updateCpBo`。
+- 新增金蝶单据调用时,沿用现有 `Save`、`Submit`、`Audit`、`UnAudit`、`View` 请求模型和 `isTrue` 校验方式。
+- 对云枢子表字段保持行结构,避免把多行子表压平成不可追溯的字符串。
+- 定时任务修改后,至少验证一次目标金蝶 FormId、云枢 schemaCode、关键查询字段和更新/新增分支。
+- 接口失败时保留外部接口返回的错误信息,避免吞掉金蝶或云枢的诊断内容。
+
+## 主要代码入口
+
+- `JinlunController`:HTTP 接口入口。
+- `JinlunServiceImpl`:表单触发业务、金蝶单据创建/更新、Teambition 项目创建。
+- `JinlunTaskServiceImpl`:金蝶到云枢的定时同步逻辑。
+- `CpClientImpl`:云枢 OpenAPI 封装。
+- `JinlunMapper.xml`:客户对账 SQL。

+ 18 - 0
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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;
+        }
+    }
+}

+ 46 - 0
mjava-minglei/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())));
+    }
+}

+ 95 - 0
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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;
+    }
+}

+ 11 - 0
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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);
+        }
+    }
+}

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

@@ -0,0 +1,451 @@
+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.Arrays;
+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 = "approveOrder";
+    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 Arrays.asList(TYPE, "axmt500", FORM_UUID);
+    }
+
+    @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());
+        List<Map<String, Object>> detailRows = toMapRows(detailWrapper.get("detailData"));
+        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(detailRows);
+        if (!deliveryRows.isEmpty()) {
+            formData.put(DELIVERY_TABLE_FIELD_ID, deliveryRows);
+        }
+        List<Map<String, Object>> orderRows = toOrderRows(detailRows);
+        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, "xmdcent"));
+            putIfNotBlank(row, DETAIL_ORDER_NO_FIELD_ID, value(source, "xmdcdocno"));
+            putIfNotBlank(row, DETAIL_ORDER_ITEM_FIELD_ID, toDecimal(value(source, "xmdcseq")));
+            putIfNotBlank(row, DETAIL_ITEM_SEQ_FIELD_ID, toDecimal(value(source, "xmdcseq")));
+            putIfNotBlank(row, DETAIL_BATCH_SEQ_FIELD_ID, toDecimal(value(source, "xmdcseq")));
+            putIfNotBlank(row, DETAIL_MATERIAL_CODE_FIELD_ID, value(source, "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, "xmdc002"));
+            putIfNotBlank(row, DETAIL_SALES_UNIT_FIELD_ID, value(source, "xmdc006"));
+            putIfNotBlank(row, DETAIL_DELIVERY_TYPE_FIELD_ID, value(source, "xmdc050"));
+            putIfNotBlank(row, DETAIL_TOTAL_QUANTITY_FIELD_ID, value(source, "xmdc007"));
+            putIfNotBlank(row, DETAIL_PROMISED_DATE_FIELD_ID, toDateMillis(value(source, "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) {
+            Map result = (Map) value;
+            return result;
+        }
+        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), Map.class);
+    }
+
+    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
mjava-minglei/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);
+        }
+    }
+}

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

@@ -0,0 +1,238 @@
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+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 java.util.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, java.util.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 = java.util.Collections.emptyList();
+        } else {
+            sourceRows = java.util.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 java.util.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 java.util.Collections.emptyMap();
+        }
+        return JSON.parseObject(JSON.toJSONString(value), Map.class);
+    }
+
+    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), Map.class);
+        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);
+        }
+    }
+}

+ 29 - 0
mjava-minglei/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);
+    }
+}

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

@@ -0,0 +1,275 @@
+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");
+        }
+        Object rows = ((Map<?, ?>) detailData).get("detailData");
+        if (!(rows instanceof List)) {
+            throw new IllegalArgumentException("ERP Yida push detailData.detailData must be an array");
+        }
+    }
+
+    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 = new LinkedHashMap<String, Object>((Map<String, Object>) request.getDetailData());
+
+        Object rows = wrapper.get("detailData");
+        if (rows instanceof List) {
+            List<Object> normalizedRows = new ArrayList<Object>();
+            for (Object row : (List<?>) rows) {
+                if (row instanceof Map) {
+                    Map<String, Object> normalizedRow = new LinkedHashMap<String, Object>((Map<String, Object>) row);
+                    applyMainDataDefaults(normalizedRow, mainData, originatorUserId);
+                    normalizedRows.add(normalizedRow);
+                } else {
+                    normalizedRows.add(row);
+                }
+            }
+            wrapper.put("detailData", normalizedRows);
+        }
+
+        request.setDetailData(wrapper);
+    }
+
+    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
mjava-minglei/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);
+        }
+    }
+}

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

@@ -0,0 +1,463 @@
+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.Arrays;
+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";
+    public static final String TYPE = "ml_work_order_craft_price";
+    private static final String FORM_NAME = "ML-工单工艺工价审定";
+    private static final String FORM_UUID = "FORM-4403B45A7F184CB79088FCDAFD2FD925LZ3K";
+    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 Arrays.asList(TYPE, "work_order_craft_price", FORM_NAME, FORM_UUID);
+    }
+
+    @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) {
+            Map result = (Map) value;
+            return result;
+        }
+        return JSON.parseObject(JSON.toJSONString(value), Map.class);
+    }
+
+    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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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
mjava-minglei/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);
+}

+ 17 - 0
mjava-minglei/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='员工花名册表';

+ 58 - 0
mjava-minglei/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"));
+    }
+}

+ 480 - 0
mjava-minglei/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);
+    }
+}

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

@@ -0,0 +1,206 @@
+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
+    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
+    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 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 userWithTitle(String userId, String name, String jobNumber, String title) {
+        Map user = user(userId, name, jobNumber);
+        user.put("title", title);
+        return user;
+    }
+
+    private static Map mapOf(String key, Object value) {
+        Map<String, Object> map = new LinkedHashMap<String, Object>();
+        map.put(key, value);
+        return map;
+    }
+
+    private static Map 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 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 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 rosterField(String fieldCode, String value) {
+        return rosterField(fieldCode, value, value);
+    }
+
+    private static Map 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
mjava-minglei/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\":\"\"}]"
+                + "}}}}";
+    }
+}

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

@@ -0,0 +1,150 @@
+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
+    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> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("xmdcent", "87");
+        detailRow.put("xmdcdocno", "SO-001");
+        detailRow.put("xmdcseq", "10");
+        detailRow.put("xmdc001", "ITEM-001");
+        detailRow.put("xmdc002", "FEATURE-001");
+        detailRow.put("imaal003", "item-name");
+        detailRow.put("imaal004", "spec-001");
+        detailRow.put("xmdc006", "PCS");
+        detailRow.put("xmdc007", "100");
+        detailRow.put("xmdc012", "2026-09-30");
+        detailRow.put("xmdc015", "12.3");
+        detailRow.put("xmdc019", "child-feature");
+        detailRow.put("xmdc046", "1000");
+        detailRow.put("xmdc047", "1130");
+        detailRow.put("xmdc050", "normal-delivery");
+        detailRow.put("xmdcua003", "2026-09-20");
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData", Collections.singletonList(detailRow));
+
+        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("10", orderRow.getString("numberField_mt82p0ah"));
+        assertEquals("10", orderRow.getString("numberField_mt82p0ai"));
+        assertEquals("ITEM-001", orderRow.getString("textField_8yabff0mk"));
+        assertEquals("item-name", orderRow.getString("textField_8yabgv4yp"));
+        assertEquals("spec-001", orderRow.getString("textField_8yabhgci5"));
+        assertEquals("FEATURE-001", orderRow.getString("textField_8yabis8xh"));
+        assertEquals("PCS", orderRow.getString("textField_8yabjecf7"));
+        assertEquals("normal-delivery", orderRow.getString("textField_mt82p0aj"));
+        assertEquals("100", orderRow.getString("textField_8yabl1tlp"));
+        assertEquals(Long.valueOf(1790697600000L), orderRow.getLong("dateField_8yabm8rmr"));
+    }
+}

+ 106 - 0
mjava-minglei/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"));
+    }
+}

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

@@ -0,0 +1,195 @@
+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.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 = (Map<String, Object>) yidaRequest.getFields().get("detailData");
+        assertEquals(Collections.singleton("detailData"), pushedDetailData.keySet());
+        Map<String, Object> pushedDetailRow = (Map<String, Object>) ((java.util.List<?>) pushedDetailData.get("detailData")).get(0);
+        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 = (Map<String, Object>) 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 = (Map<String, Object>) request.getDetailData();
+        Map<String, Object> pushedDetailRow = (Map<String, Object>) ((java.util.List<?>) pushedDetailData.get("detailData")).get(0);
+        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;
+    }
+}

+ 72 - 0
mjava-minglei/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);
+    }
+}

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

@@ -0,0 +1,182 @@
+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
+    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("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
mjava-minglei/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());
+    }
+}