2 İşlemeler f389c4476b ... 7c82ec364a

Yazar SHA1 Mesaj Tarih
  ht 7c82ec364a 接口对接 5 gün önce
  ht c35ae02e93 chore: prepare minglei project push 5 gün önce
47 değiştirilmiş dosya ile 2106 ekleme ve 373 silme
  1. 257 280
      mjava-minglei/README.md
  2. 8 0
      mjava-minglei/src/main/java/com/malk/minglei/MjavaMingleiApplication.java
  3. 8 0
      mjava-minglei/src/main/java/com/malk/minglei/config/DingTalkRosterConfiguration.java
  4. 15 0
      mjava-minglei/src/main/java/com/malk/minglei/config/EmployeeRosterConfiguration.java
  5. 6 0
      mjava-minglei/src/main/java/com/malk/minglei/config/EmployeeRosterProperties.java
  6. 15 0
      mjava-minglei/src/main/java/com/malk/minglei/config/HttpRequestResponseLoggingFilter.java
  7. 23 0
      mjava-minglei/src/main/java/com/malk/minglei/config/MjavaYidaConfiguration.java
  8. 6 0
      mjava-minglei/src/main/java/com/malk/minglei/config/YidaApplicationsProperties.java
  9. 26 0
      mjava-minglei/src/main/java/com/malk/minglei/controller/EmployeeRosterController.java
  10. 33 0
      mjava-minglei/src/main/java/com/malk/minglei/controller/GlobalExceptionHandler.java
  11. 5 0
      mjava-minglei/src/main/java/com/malk/minglei/controller/MingleiPortController.java
  12. 17 1
      mjava-minglei/src/main/java/com/malk/minglei/controller/YidaProcessController.java
  13. 9 3
      mjava-minglei/src/main/java/com/malk/minglei/dto/DepartmentUserQueryRequest.java
  14. 3 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/EmployeeRosterInfo.java
  15. 3 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/EmployeeRosterQueryRequest.java
  16. 12 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/EmployeeRosterSyncResult.java
  17. 16 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/MingleiPortRequest.java
  18. 3 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/StartProcessRequest.java
  19. 3 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/YidaOperationParam.java
  20. 19 0
      mjava-minglei/src/main/java/com/malk/minglei/service/employee/DingTalkEmployeeRosterClient.java
  21. 27 0
      mjava-minglei/src/main/java/com/malk/minglei/service/employee/EmployeeRosterService.java
  22. 25 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/DingTalkEmployeeRosterClientImpl.java
  23. 3 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRecord.java
  24. 27 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepository.java
  25. 11 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterScheduler.java
  26. 42 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImpl.java
  27. 59 20
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandler.java
  28. 7 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/BomYidaProcessHandler.java
  29. 42 9
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/Cpmp402YidaProcessHandler.java
  30. 413 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/CustomerReleaseFormPushHandler.java
  31. 11 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/DingTalkYidaProcessOpenApiClient.java
  32. 29 8
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImpl.java
  33. 35 4
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImpl.java
  34. 437 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandler.java
  35. 32 6
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/MlWorkOrderCraftPriceFormPushHandler.java
  36. 5 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/YidaProcessServiceImpl.java
  37. 5 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/MingleiDocumentService.java
  38. 11 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/YidaFormPushHandler.java
  39. 6 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/YidaProcessOpenApiClient.java
  40. 3 0
      mjava-minglei/src/main/java/com/malk/minglei/service/yida/YidaProcessService.java
  41. 11 9
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java
  42. 43 28
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandlerTest.java
  43. 115 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/CustomerReleaseFormPushHandlerTest.java
  44. 55 5
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImplTest.java
  45. 27 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImplTest.java
  46. 136 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandlerTest.java
  47. 2 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/MlWorkOrderCraftPriceFormPushHandlerTest.java

Dosya farkı çok büyük olduğundan ihmal edildi
+ 257 - 280
mjava-minglei/README.md


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

@@ -4,10 +4,18 @@ import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.scheduling.annotation.EnableScheduling;
 
+/**
+ * 明磊业务集成服务启动入口。
+ */
 @EnableScheduling
 @SpringBootApplication
 public class MjavaMingleiApplication {
 
+    /**
+     * 启动 Spring Boot 应用。
+     *
+     * @param args 命令行启动参数
+     */
     public static void main(String[] args) {
         SpringApplication.run(MjavaMingleiApplication.class, args);
     }

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

@@ -5,9 +5,17 @@ import org.springframework.context.annotation.Configuration;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.web.client.RestTemplate;
 
+/**
+ * 钉钉花名册调用相关的客户端配置。
+ */
 @Configuration
 public class DingTalkRosterConfiguration {
 
+    /**
+     * 创建用于调用钉钉花名册接口的 HTTP 客户端。
+     *
+     * @return 已配置连接和读取超时时间的 RestTemplate
+     */
     @Bean("dingTalkRestTemplate")
     public RestTemplate dingTalkRestTemplate() {
         SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();

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

@@ -11,10 +11,19 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource;
 
 import javax.sql.DataSource;
 
+/**
+ * 员工花名册独立数据源和 JDBC 访问对象配置。
+ */
 @Configuration
 @EnableConfigurationProperties(EmployeeRosterProperties.class)
 public class EmployeeRosterConfiguration {
 
+    /**
+     * 根据员工花名册配置创建独立数据源。
+     *
+     * @param properties 员工花名册配置项
+     * @return 员工花名册数据源
+     */
     @Bean("employeeRosterDataSource")
     @ConditionalOnExpression("'${minglei.employee-roster.datasource.url:}' != ''")
     public DataSource employeeRosterDataSource(EmployeeRosterProperties properties) {
@@ -33,6 +42,12 @@ public class EmployeeRosterConfiguration {
         return dataSource;
     }
 
+    /**
+     * 创建访问员工花名册库表的 JdbcTemplate。
+     *
+     * @param dataSource 员工花名册数据源
+     * @return 员工花名册 JDBC 操作模板
+     */
     @Bean("employeeRosterJdbcTemplate")
     @ConditionalOnBean(name = "employeeRosterDataSource")
     public JdbcTemplate employeeRosterJdbcTemplate(@Qualifier("employeeRosterDataSource") DataSource dataSource) {

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

@@ -2,6 +2,9 @@ package com.malk.minglei.config;
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
+/**
+ * 员工花名册同步和本地存储配置。
+ */
 @ConfigurationProperties(prefix = "minglei.employee-roster")
 public class EmployeeRosterProperties {
 
@@ -64,6 +67,9 @@ public class EmployeeRosterProperties {
         this.datasource = datasource;
     }
 
+    /**
+     * 员工花名册独立数据源配置。
+     */
     public static class DataSourceProperties {
 
         private String url;

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

@@ -32,6 +32,12 @@ public class HttpRequestResponseLoggingFilter extends OncePerRequestFilter {
             "((?:password|secret|token|authorization|accessToken|systemToken|key)=)[^&]*",
             Pattern.CASE_INSENSITIVE);
 
+    /**
+     * 跳过不适合读取请求体的二进制或表单文件上传请求。
+     *
+     * @param request 当前 HTTP 请求
+     * @return true 表示跳过日志过滤
+     */
     @Override
     protected boolean shouldNotFilter(HttpServletRequest request) {
         String contentType = request.getContentType();
@@ -39,6 +45,15 @@ public class HttpRequestResponseLoggingFilter extends OncePerRequestFilter {
                 || contentType.startsWith("application/octet-stream"));
     }
 
+    /**
+     * 记录请求和响应摘要,并对敏感字段做脱敏处理。
+     *
+     * @param request 当前 HTTP 请求
+     * @param response 当前 HTTP 响应
+     * @param filterChain 过滤器链
+     * @throws ServletException Servlet 处理异常
+     * @throws IOException IO 处理异常
+     */
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
             throws ServletException, IOException {

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

@@ -17,21 +17,44 @@ import org.springframework.context.annotation.Import;
 
 import java.lang.reflect.Field;
 
+/**
+ * 宜搭和钉钉开放平台客户端配置。
+ */
 @Configuration
 @EnableConfigurationProperties({DDConf.class, YidaApplicationsProperties.class})
 @Import({DDImplClient.class, DDImplClient_Contacts.class, DDImplClient_Personnel.class})
 public class MjavaYidaConfiguration {
 
+    /**
+     * 创建协同办公应用的宜搭普通表单客户端。
+     *
+     * @param dingTalkClient 钉钉开放平台客户端
+     * @param applications 宜搭应用配置
+     * @return 协同办公应用宜搭客户端
+     */
     @Bean("coordinationOfficeYidaClient")
     public YDClient coordinationOfficeYidaClient(DDClient dingTalkClient, YidaApplicationsProperties applications) {
         return createYidaClient(dingTalkClient, applications.getCoordinationOffice());
     }
 
+    /**
+     * 创建主数据应用的宜搭普通表单客户端。
+     *
+     * @param dingTalkClient 钉钉开放平台客户端
+     * @param applications 宜搭应用配置
+     * @return 主数据应用宜搭客户端
+     */
     @Bean("masterDataYidaClient")
     public YDClient masterDataYidaClient(DDClient dingTalkClient, YidaApplicationsProperties applications) {
         return createYidaClient(dingTalkClient, applications.getMasterData());
     }
 
+    /**
+     * 创建协同办公应用的宜搭流程客户端。
+     *
+     * @param dingTalkClient 钉钉开放平台客户端
+     * @return 协同办公应用宜搭流程客户端
+     */
     @Bean("coordinationOfficeYidaProcessClient")
     public YDClient_Process coordinationOfficeYidaProcessClient(DDClient dingTalkClient) {
         YDClient_ProcessImpl yidaProcessClient = new YDClient_ProcessImpl();

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

@@ -2,6 +2,9 @@ package com.malk.minglei.config;
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
+/**
+ * 宜搭应用凭据配置集合。
+ */
 @ConfigurationProperties(prefix = "minglei.yida")
 public class YidaApplicationsProperties {
 
@@ -24,6 +27,9 @@ public class YidaApplicationsProperties {
         this.masterData = masterData;
     }
 
+    /**
+     * 单个宜搭应用的访问凭据。
+     */
     public static class ApplicationCredentials {
 
         private String appType;

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

@@ -17,6 +17,9 @@ import javax.validation.Valid;
 import javax.validation.constraints.Max;
 import javax.validation.constraints.Min;
 
+/**
+ * 员工花名册同步和查询接口。
+ */
 @Validated
 @RestController
 @RequestMapping("/api/yida/minglei/employee-roster")
@@ -24,21 +27,44 @@ public class EmployeeRosterController {
 
     private final EmployeeRosterService employeeRosterService;
 
+    /**
+     * 创建员工花名册控制器。
+     *
+     * @param employeeRosterService 员工花名册服务
+     */
     public EmployeeRosterController(EmployeeRosterService employeeRosterService) {
         this.employeeRosterService = employeeRosterService;
     }
 
+    /**
+     * 按指定部门同步钉钉员工花名册。
+     *
+     * @param departmentId 钉钉部门 ID
+     * @param type 查询范围,0 表示包含子部门,1 表示仅当前部门
+     * @return 员工花名册同步结果
+     */
     @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)));
     }
 
+    /**
+     * 按默认配置完整同步员工花名册。
+     *
+     * @return 员工花名册同步结果
+     */
     @PostMapping("/sync-full")
     public ResponseEntity<ApiResponse<EmployeeRosterSyncResult>> syncFull() {
         return ResponseEntity.ok(ApiResponse.success(employeeRosterService.syncFull()));
     }
 
+    /**
+     * 根据钉钉用户 ID 查询本地员工花名册信息。
+     *
+     * @param request 员工花名册查询请求
+     * @return 员工花名册信息
+     */
     @PostMapping("/getUserRoster")
     public ResponseEntity<ApiResponse<EmployeeRosterInfo>> getUserRoster(@Valid @RequestBody EmployeeRosterQueryRequest request) {
         return ResponseEntity.ok(ApiResponse.success(employeeRosterService.findByUserId(request.getUserID())));

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

@@ -14,28 +14,55 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
+/**
+ * REST 接口全局异常处理器。
+ */
 @RestControllerAdvice
 public class GlobalExceptionHandler {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
 
+    /**
+     * 处理缺少必要运行配置导致的请求失败。
+     *
+     * @param exception 配置状态异常
+     * @return 预条件失败响应
+     */
     @ExceptionHandler(IllegalStateException.class)
     public ResponseEntity<Map<String, String>> handleConfiguration(IllegalStateException exception) {
         return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED)
                 .body(Collections.singletonMap("message", exception.getMessage()));
     }
 
+    /**
+     * 处理本地参数校验或业务参数不支持的异常。
+     *
+     * @param exception 参数异常
+     * @return 请求参数错误响应
+     */
     @ExceptionHandler(IllegalArgumentException.class)
     public ResponseEntity<Map<String, String>> handleInvalidArgument(IllegalArgumentException exception) {
         return ResponseEntity.badRequest()
                 .body(Collections.singletonMap("message", defaultText(exception.getMessage(), "Request parameter is invalid")));
     }
 
+    /**
+     * 处理请求体字段校验失败或 JSON 解析失败的异常。
+     *
+     * @param exception 请求体解析或校验异常
+     * @return 请求参数错误响应
+     */
     @ExceptionHandler({MethodArgumentNotValidException.class, HttpMessageNotReadableException.class})
     public ResponseEntity<Map<String, String>> handleBadRequest(Exception exception) {
         return ResponseEntity.badRequest().body(Collections.singletonMap("message", "Request validation failed"));
     }
 
+    /**
+     * 处理宜搭 SDK 调用失败。
+     *
+     * @param exception 宜搭客户端异常
+     * @return 外部服务调用失败响应
+     */
     @ExceptionHandler(McException.class)
     public ResponseEntity<Map<String, String>> handleYidaClient(McException exception) {
         LOGGER.error("Yida client request failed code={} source={}", exception.getCode(), exception.getSource(), exception);
@@ -46,6 +73,12 @@ public class GlobalExceptionHandler {
         return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(response);
     }
 
+    /**
+     * 处理未被更具体规则捕获的异常。
+     *
+     * @param exception 未知异常
+     * @return 外部服务调用失败响应
+     */
     @ExceptionHandler(Exception.class)
     public ResponseEntity<Map<String, String>> handleUnexpected(Exception exception) {
         LOGGER.error("Yida client request failed", exception);

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

@@ -31,6 +31,11 @@ public class MingleiPortController {
 
     private final MingleiPortService mingleiPortService;
 
+    /**
+     * 创建明磊外部单据接口控制器。
+     *
+     * @param mingleiPortService 明磊外部单据接口服务
+     */
     public MingleiPortController(MingleiPortService mingleiPortService) {
         this.mingleiPortService = mingleiPortService;
     }

+ 17 - 1
mjava-minglei/src/main/java/com/malk/minglei/controller/YidaProcessController.java

@@ -26,6 +26,9 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
+/**
+ * 宜搭流程发起、单据查询和部门成员查询接口。
+ */
 @Validated
 @RestController
 @RequestMapping("/api/yida")
@@ -34,6 +37,12 @@ public class YidaProcessController {
     private final YidaProcessService yidaProcessService;
     private final MingleiDocumentService mingleiDocumentService;
 
+    /**
+     * 创建宜搭业务接口控制器。
+     *
+     * @param yidaProcessService 宜搭流程服务
+     * @param mingleiDocumentService 明磊单据查询服务
+     */
     public YidaProcessController(YidaProcessService yidaProcessService, MingleiDocumentService mingleiDocumentService) {
         this.yidaProcessService = yidaProcessService;
         this.mingleiDocumentService = mingleiDocumentService;
@@ -75,6 +84,13 @@ public class YidaProcessController {
         return ResponseEntity.badRequest().body(Collections.singletonMap("message", "Request validation failed"));
     }
 
+    /**
+     * 分页查询明磊普通表单实例。
+     *
+     * @param page 页码,从 1 开始
+     * @param size 每页记录数
+     * @return 宜搭表单实例分页结果
+     */
     @GetMapping("/minglei/forms/instances")
     public ResponseEntity<Map<String, Object>> queryMingleiDocuments(@RequestParam(name = "page", defaultValue = "1") @Min(1) int page,
                                                                       @RequestParam(name = "size", defaultValue = "20") @Min(1) @Max(100) int size) {
@@ -89,7 +105,7 @@ public class YidaProcessController {
      */
     @PostMapping("/minglei/departments/users")
     public ResponseEntity<DepartmentUserIdsResponse> listDepartmentUserIds(@Valid @RequestBody DepartmentUserQueryRequest request) {
-        List<String> userIds = mingleiDocumentService.listDepartmentUserIds(request.getDepartmentId(), request.getType());
+        List<String> userIds = mingleiDocumentService.listDepartmentUserIds(request.getDepartmentIds(), request.getType());
         return ResponseEntity.ok(DepartmentUserIdsResponse.success(userIds));
     }
 

+ 9 - 3
mjava-minglei/src/main/java/com/malk/minglei/dto/DepartmentUserQueryRequest.java

@@ -7,6 +7,8 @@ import javax.validation.constraints.Max;
 import javax.validation.constraints.Min;
 import javax.validation.constraints.NotNull;
 import javax.validation.constraints.Pattern;
+import java.util.ArrayList;
+import java.util.List;
 
 /**
  * 按部门查询钉钉成员的请求模型。
@@ -14,7 +16,7 @@ import javax.validation.constraints.Pattern;
 public class DepartmentUserQueryRequest {
 
     @NotBlank
-    @Pattern(regexp = "[1-9]\\d*", message = "dept_Id must be a positive integer")
+    @Pattern(regexp = "\\s*[1-9]\\d*\\s*(,\\s*[1-9]\\d*\\s*)*", message = "dept_Id must be comma-separated positive integers")
     @JsonProperty("dept_Id")
     private String departmentId;
 
@@ -23,8 +25,12 @@ public class DepartmentUserQueryRequest {
     @Max(value = 1, message = "type must be 0 or 1")
     private Integer type;
 
-    public long getDepartmentId() {
-        return Long.parseLong(departmentId);
+    public List<Long> getDepartmentIds() {
+        List<Long> departmentIds = new ArrayList<Long>();
+        for (String value : departmentId.split(",")) {
+            departmentIds.add(Long.parseLong(value.trim()));
+        }
+        return departmentIds;
     }
 
     public void setDepartmentId(String departmentId) {

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

@@ -1,5 +1,8 @@
 package com.malk.minglei.dto;
 
+/**
+ * 员工花名册查询结果。
+ */
 public class EmployeeRosterInfo {
 
     private Integer id;

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

@@ -4,6 +4,9 @@ import com.fasterxml.jackson.annotation.JsonProperty;
 
 import javax.validation.constraints.NotBlank;
 
+/**
+ * 员工花名册查询请求。
+ */
 public class EmployeeRosterQueryRequest {
 
     @NotBlank

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

@@ -1,5 +1,8 @@
 package com.malk.minglei.dto;
 
+/**
+ * 员工花名册同步结果统计。
+ */
 public class EmployeeRosterSyncResult {
 
     private final long departmentId;
@@ -8,6 +11,15 @@ public class EmployeeRosterSyncResult {
     private final int fieldCount;
     private final int syncedCount;
 
+    /**
+     * 创建员工花名册同步结果。
+     *
+     * @param departmentId 同步的钉钉部门 ID
+     * @param includeSubDepartments 是否包含子部门
+     * @param userCount 本次处理的用户数
+     * @param fieldCount 本次查询的花名册字段数
+     * @param syncedCount 实际写入本地库的记录数
+     */
     public EmployeeRosterSyncResult(long departmentId, boolean includeSubDepartments, int userCount, int fieldCount, int syncedCount) {
         this.departmentId = departmentId;
         this.includeSubDepartments = includeSubDepartments;

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

@@ -29,14 +29,27 @@ public class MingleiPortRequest {
         return detail;
     }
 
+    /**
+     * 返回单据编号,便于日志和外部调用跟踪。
+     *
+     * @return 单据编号;请求头为空时返回 null
+     */
     public String getDocumentNumber() {
         return head == null ? null : head.pmdidocno;
     }
 
+    /**
+     * 返回明细行数,便于日志记录。
+     *
+     * @return 明细行数量;明细为空时返回 0
+     */
     public int getDetailCount() {
         return detail == null ? 0 : detail.size();
     }
 
+    /**
+     * 供应商价格审批单头信息。
+     */
     @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
     public static class Head {
 
@@ -74,6 +87,9 @@ public class MingleiPortRequest {
         private String pmdistus;
     }
 
+    /**
+     * 供应商价格审批单明细信息。
+     */
     @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
     public static class Detail {
 

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

@@ -7,6 +7,9 @@ import javax.validation.constraints.NotEmpty;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
+/**
+ * 兼容旧接口的宜搭流程发起请求模型。
+ */
 public class StartProcessRequest {
 
     @NotBlank

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

@@ -2,6 +2,9 @@ package com.malk.minglei.dto;
 
 import com.malk.server.aliwork.YDParam;
 
+/**
+ * 宜搭数据操作参数扩展模型。
+ */
 public class YidaOperationParam extends YDParam {
 
     private String title;

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

@@ -3,9 +3,28 @@ package com.malk.minglei.service.employee;
 import java.util.List;
 import java.util.Map;
 
+/**
+ * 钉钉智能人事花名册接口客户端。
+ */
 public interface DingTalkEmployeeRosterClient {
 
+    /**
+     * 查询可用的花名册字段分组。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param agentId 应用 AgentId
+     * @return 花名册字段分组列表
+     */
     List<Map> listRosterFieldGroups(String accessToken, Number agentId);
 
+    /**
+     * 批量查询指定用户的花名册字段值。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param userIds 钉钉用户 ID 列表
+     * @param agentId 应用 AgentId
+     * @param fieldCodes 需要查询的字段编码
+     * @return 用户花名册记录列表
+     */
     List<Map> listEmployeeRoster(String accessToken, List<String> userIds, Number agentId, List<String> fieldCodes);
 }

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

@@ -3,13 +3,40 @@ package com.malk.minglei.service.employee;
 import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.EmployeeRosterSyncResult;
 
+/**
+ * 员工花名册同步和查询服务。
+ */
 public interface EmployeeRosterService {
 
+    /**
+     * 按指定部门和查询范围同步员工花名册。
+     *
+     * @param departmentId 钉钉部门 ID
+     * @param type 查询范围,0 表示包含子部门,1 表示仅当前部门
+     * @return 同步统计结果
+     */
     EmployeeRosterSyncResult sync(long departmentId, int type);
 
+    /**
+     * 使用配置中的默认部门完整同步员工花名册。
+     *
+     * @return 同步统计结果
+     */
     EmployeeRosterSyncResult syncFull();
 
+    /**
+     * 根据钉钉用户 ID 查询员工花名册。
+     *
+     * @param userId 钉钉用户 ID
+     * @return 员工花名册信息,不存在时返回 null
+     */
     EmployeeRosterInfo findByUserId(String userId);
 
+    /**
+     * 根据员工工号查询员工花名册。
+     *
+     * @param employeeNo 员工工号
+     * @return 员工花名册信息,不存在时返回 null
+     */
     EmployeeRosterInfo findByEmployeeNo(String employeeNo);
 }

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

@@ -18,6 +18,9 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
+/**
+ * 基于钉钉开放接口和 SDK 的智能人事花名册客户端实现。
+ */
 @Service
 public class DingTalkEmployeeRosterClientImpl implements DingTalkEmployeeRosterClient {
 
@@ -27,12 +30,25 @@ public class DingTalkEmployeeRosterClientImpl implements DingTalkEmployeeRosterC
     private final RestTemplate restTemplate;
     private final DDClient_Personnel personnelClient;
 
+    /**
+     * 创建钉钉花名册客户端实现。
+     *
+     * @param restTemplate 钉钉接口专用 HTTP 客户端
+     * @param personnelClient 钉钉智能人事客户端
+     */
     public DingTalkEmployeeRosterClientImpl(@Qualifier("dingTalkRestTemplate") RestTemplate restTemplate,
                                             DDClient_Personnel personnelClient) {
         this.restTemplate = restTemplate;
         this.personnelClient = personnelClient;
     }
 
+    /**
+     * 查询钉钉花名册字段分组,并转换为 Map 列表。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param agentId 应用 AgentId
+     * @return 花名册字段分组列表
+     */
     @Override
     public List<Map> listRosterFieldGroups(String accessToken, Number agentId) {
         Map<String, Object> requestBody = new LinkedHashMap<String, Object>();
@@ -56,6 +72,15 @@ public class DingTalkEmployeeRosterClientImpl implements DingTalkEmployeeRosterC
         return toMapList(extractGroupList(result));
     }
 
+    /**
+     * 批量查询指定用户的花名册数据。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param userIds 钉钉用户 ID 列表
+     * @param agentId 应用 AgentId
+     * @param fieldCodes 需要查询的字段编码
+     * @return 用户花名册记录列表
+     */
     @Override
     public List<Map> listEmployeeRoster(String accessToken, List<String> userIds, Number agentId, List<String> fieldCodes) {
         if (userIds == null || userIds.isEmpty()) {

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

@@ -1,5 +1,8 @@
 package com.malk.minglei.service.impl.employee;
 
+/**
+ * 员工花名册持久化记录。
+ */
 public class EmployeeRosterRecord {
 
     private String dingtalkId;

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

@@ -12,18 +12,33 @@ 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;
 
+    /**
+     * 创建员工花名册仓储组件。
+     *
+     * @param jdbcTemplateProvider 员工花名册 JDBC 模板提供器
+     * @param properties 员工花名册配置
+     */
     public EmployeeRosterRepository(@Qualifier("employeeRosterJdbcTemplate") ObjectProvider<JdbcTemplate> jdbcTemplateProvider,
                                     EmployeeRosterProperties properties) {
         this.jdbcTemplateProvider = jdbcTemplateProvider;
         this.properties = properties;
     }
 
+    /**
+     * 批量新增或更新员工花名册记录。
+     *
+     * @param records 待写入的员工花名册记录
+     * @return 实际提交写入的记录数
+     */
     public int upsertAll(List<EmployeeRosterRecord> records) {
         if (records == null || records.isEmpty()) {
             return 0;
@@ -37,10 +52,22 @@ public class EmployeeRosterRepository {
         return records.size();
     }
 
+    /**
+     * 根据钉钉用户 ID 查询员工花名册。
+     *
+     * @param dingtalkId 钉钉用户 ID
+     * @return 员工花名册信息,不存在时返回 null
+     */
     public EmployeeRosterInfo findByDingtalkId(String dingtalkId) {
         return findOne("dingtalk_id", dingtalkId);
     }
 
+    /**
+     * 根据员工工号查询员工花名册。
+     *
+     * @param employeeNo 员工工号
+     * @return 员工花名册信息,不存在时返回 null
+     */
     public EmployeeRosterInfo findByEmployeeNo(String employeeNo) {
         return findOne("employee_no", employeeNo);
     }

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

@@ -7,6 +7,9 @@ import org.slf4j.LoggerFactory;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
+/**
+ * 员工花名册定时同步任务。
+ */
 @Component
 public class EmployeeRosterScheduler {
 
@@ -14,10 +17,18 @@ public class EmployeeRosterScheduler {
 
     private final EmployeeRosterService employeeRosterService;
 
+    /**
+     * 创建员工花名册定时任务。
+     *
+     * @param employeeRosterService 员工花名册服务
+     */
     public EmployeeRosterScheduler(EmployeeRosterService employeeRosterService) {
         this.employeeRosterService = employeeRosterService;
     }
 
+    /**
+     * 每日执行员工花名册全量同步。
+     */
     @Scheduled(cron = "0 0 0 * * ?")
     public void syncFullEmployeeRoster() {
         try {

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

@@ -18,6 +18,9 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+/**
+ * 员工花名册同步与查询服务实现。
+ */
 @Service
 public class EmployeeRosterServiceImpl implements EmployeeRosterService {
 
@@ -34,6 +37,16 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
     private final EmployeeRosterRepository repository;
     private final EmployeeRosterProperties properties;
 
+    /**
+     * 创建员工花名册服务实现。
+     *
+     * @param dingTalkClient 钉钉基础客户端
+     * @param contactsClient 钉钉通讯录客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param rosterClient 钉钉花名册客户端
+     * @param repository 员工花名册本地仓储
+     * @param properties 员工花名册同步配置
+     */
     public EmployeeRosterServiceImpl(DDClient dingTalkClient,
                                      DDClient_Contacts contactsClient,
                                      DDConf dingTalkConfiguration,
@@ -48,6 +61,13 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         this.properties = properties;
     }
 
+    /**
+     * 从钉钉同步指定部门范围内的员工花名册,并写入本地表。
+     *
+     * @param departmentId 钉钉部门 ID
+     * @param type 查询范围,0 表示包含子部门,1 表示仅当前部门
+     * @return 本次同步统计结果
+     */
     @Override
     public EmployeeRosterSyncResult sync(long departmentId, int type) {
         if (departmentId < 1) {
@@ -84,17 +104,33 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         return new EmployeeRosterSyncResult(departmentId, type == INCLUDE_CHILD_DEPARTMENTS, records.size(), fieldCodes.size(), syncedCount);
     }
 
+    /**
+     * 使用配置中的默认部门和范围同步员工花名册。
+     *
+     * @return 本次同步统计结果
+     */
     public EmployeeRosterSyncResult syncDefaultDepartment() {
         long departmentId = properties.getDepartmentId();
         int type = properties.isIncludeSubDepartments() ? INCLUDE_CHILD_DEPARTMENTS : CURRENT_DEPARTMENT_ONLY;
         return sync(departmentId, type);
     }
 
+    /**
+     * 从根部门开始完整同步员工花名册。
+     *
+     * @return 本次同步统计结果
+     */
     @Override
     public EmployeeRosterSyncResult syncFull() {
         return sync(ROOT_DEPARTMENT_ID, INCLUDE_CHILD_DEPARTMENTS);
     }
 
+    /**
+     * 根据钉钉用户 ID 查询本地员工花名册记录。
+     *
+     * @param userId 钉钉用户 ID
+     * @return 员工花名册信息,不存在时返回 null
+     */
     @Override
     public EmployeeRosterInfo findByUserId(String userId) {
         if (userId == null || userId.trim().isEmpty()) {
@@ -103,6 +139,12 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         return repository.findByDingtalkId(userId.trim());
     }
 
+    /**
+     * 根据员工工号查询本地员工花名册记录。
+     *
+     * @param employeeNo 员工工号
+     * @return 员工花名册信息,不存在时返回 null
+     */
     @Override
     public EmployeeRosterInfo findByEmployeeNo(String employeeNo) {
         if (employeeNo == null || employeeNo.trim().isEmpty()) {

+ 59 - 20
mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandler.java

@@ -17,18 +17,20 @@ 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;
 
+/**
+ * 销售订单审批 ERP 推送到宜搭流程的处理器。
+ */
 @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";
+    public static final String TYPE = "axmt500";
     private static final String FORM_NAME = "approveOrder";
     private static final String FORM_UUID = "FORM-8454FA17732746058A2D90B1C559CC19G5SR";
     private static final String PROCESS_CODE = "TPROC--9VD66QA18AJ8317KK3NXM54W1HIT30C7N28TM7";
@@ -85,6 +87,13 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
     private final DDConf dingTalkConfiguration;
     private final YidaApplicationsProperties applications;
 
+    /**
+     * 创建销售订单审批推送处理器。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     */
     public ApproveOrderFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
                                        DDConf dingTalkConfiguration,
                                        YidaApplicationsProperties applications) {
@@ -93,11 +102,22 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
         this.applications = applications;
     }
 
+    /**
+     * 返回销售订单审批支持的 ERP 推送类型。
+     *
+     * @return 支持的 type 列表
+     */
     @Override
     public List<String> getTypes() {
-        return Arrays.asList(TYPE, "axmt500", FORM_UUID);
+        return Collections.singletonList(TYPE);
     }
 
+    /**
+     * 将销售订单审批数据转换为宜搭流程表单并发起流程。
+     *
+     * @param request ERP 推送请求
+     * @return 宜搭流程实例 ID 或成功标识
+     */
     @Override
     public String push(ErpYidaPushRequest request) {
         validateConfiguration();
@@ -141,7 +161,17 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
         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"));
+        Object legacyRows = detailWrapper.get("detailData");
+        Object numberedRows1 = detailWrapper.get("detailData1");
+        Object numberedRows2 = detailWrapper.get("detailData2");
+        Object deliverySource = firstNonBlank(numberedRows1, legacyRows);
+        Object orderSource = firstNonBlank(numberedRows2, legacyRows);
+        if (isBlank(numberedRows2) && !isBlank(legacyRows) && !isBlank(numberedRows1)) {
+            deliverySource = legacyRows;
+            orderSource = numberedRows1;
+        }
+        List<Map<String, Object>> deliverySourceRows = toMapRows(deliverySource);
+        List<Map<String, Object>> orderSourceRows = toMapRows(orderSource);
         String orderNo = toText(value(main, "xmdadocno"));
         String title = toText(firstNonBlank(value(main, "title", "dataTitle"), orderNo == null ? null : "approveOrder-" + orderNo));
 
@@ -169,11 +199,11 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
         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);
+        List<Map<String, Object>> deliveryRows = toDeliveryRows(deliverySourceRows);
         if (!deliveryRows.isEmpty()) {
             formData.put(DELIVERY_TABLE_FIELD_ID, deliveryRows);
         }
-        List<Map<String, Object>> orderRows = toOrderRows(detailRows);
+        List<Map<String, Object>> orderRows = toOrderRows(orderSourceRows);
         if (!orderRows.isEmpty()) {
             formData.put(DETAIL_TABLE_FIELD_ID, orderRows);
         }
@@ -204,19 +234,19 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
         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_ENTERPRISE_NO_FIELD_ID, value(source, "xmddent", "xmdcent"));
+            putIfNotBlank(row, DETAIL_ORDER_NO_FIELD_ID, value(source, "xmdddocno", "xmdcdocno"));
+            putIfNotBlank(row, DETAIL_ORDER_ITEM_FIELD_ID, toDecimal(value(source, "xmddseq", "xmdcseq")));
+            putIfNotBlank(row, DETAIL_ITEM_SEQ_FIELD_ID, toDecimal(value(source, "xmddseq1", "xmdcseq")));
+            putIfNotBlank(row, DETAIL_BATCH_SEQ_FIELD_ID, toDecimal(value(source, "xmddseq2", "xmdcseq")));
+            putIfNotBlank(row, DETAIL_MATERIAL_CODE_FIELD_ID, value(source, "xmdd001", "xmdc001"));
             putIfNotBlank(row, DETAIL_ITEM_NAME_FIELD_ID, value(source, "imaal003"));
             putIfNotBlank(row, DETAIL_SPECIFICATION_FIELD_ID, value(source, "imaal004"));
-            putIfNotBlank(row, DETAIL_PRODUCT_FEATURE_FIELD_ID, value(source, "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")));
+            putIfNotBlank(row, DETAIL_PRODUCT_FEATURE_FIELD_ID, value(source, "xmdd002", "xmdc002"));
+            putIfNotBlank(row, DETAIL_SALES_UNIT_FIELD_ID, value(source, "xmdd004", "xmdc006"));
+            putIfNotBlank(row, DETAIL_DELIVERY_TYPE_FIELD_ID, value(source, "xmdd009", "xmdc050"));
+            putIfNotBlank(row, DETAIL_TOTAL_QUANTITY_FIELD_ID, value(source, "xmdd005", "xmdc007"));
+            putIfNotBlank(row, DETAIL_PROMISED_DATE_FIELD_ID, toDateMillis(value(source, "xmdd011", "xmdc012")));
             copyYidaFieldValues(source, row);
             if (!row.isEmpty()) {
                 rows.add(row);
@@ -328,8 +358,7 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
             return Collections.emptyMap();
         }
         if (value instanceof Map) {
-            Map result = (Map) value;
-            return result;
+            return copyMap((Map<?, ?>) value);
         }
         if (value instanceof ErpYidaPushMainData) {
             ErpYidaPushMainData mainData = (ErpYidaPushMainData) value;
@@ -344,7 +373,17 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
             result.putAll(mainData.getExtraFields());
             return result;
         }
-        return JSON.parseObject(JSON.toJSONString(value), Map.class);
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
     }
 
     private boolean isBlank(Object value) {

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

@@ -27,6 +27,13 @@ public class BomYidaProcessHandler implements YidaProcessHandler {
     private final DDConf dingTalkConfiguration;
     private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
 
+    /**
+     * 创建 BOM 流程处理器。
+     *
+     * @param yidaClient 协同办公宜搭客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     */
     public BomYidaProcessHandler(@Qualifier("coordinationOfficeYidaClient") YDClient yidaClient,
                                  DDConf dingTalkConfiguration,
                                  YidaApplicationsProperties applications) {

+ 42 - 9
mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/Cpmp402YidaProcessHandler.java

@@ -8,14 +8,16 @@ 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.beans.factory.annotation.Qualifier;
 import org.springframework.stereotype.Service;
 
 import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -50,6 +52,13 @@ public class Cpmp402YidaProcessHandler implements YidaProcessHandler {
     private final DDConf dingTalkConfiguration;
     private final YidaApplicationsProperties applications;
 
+    /**
+     * 创建包材核价单流程处理器。
+     *
+     * @param yidaClient 协同办公宜搭客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     */
     public Cpmp402YidaProcessHandler(@Qualifier("coordinationOfficeYidaClient") YDClient yidaClient,
                                      DDConf dingTalkConfiguration,
                                      YidaApplicationsProperties applications) {
@@ -58,11 +67,22 @@ public class Cpmp402YidaProcessHandler implements YidaProcessHandler {
         this.applications = applications;
     }
 
+    /**
+     * 返回包材核价单支持的流程类型。
+     *
+     * @return 支持的 type
+     */
     @Override
     public String getType() {
         return TYPE;
     }
 
+    /**
+     * 将包材核价单数据转换为宜搭流程表单并发起流程。
+     *
+     * @param request 宜搭流程发起请求
+     * @return 宜搭流程实例 ID
+     */
     @Override
     public String startProcess(YidaProcessStartRequest request) {
         validateConfiguration();
@@ -103,12 +123,12 @@ public class Cpmp402YidaProcessHandler implements YidaProcessHandler {
 
     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>();
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
         addKnownYidaFields(rawFields, formData);
 
         Map<String, Object> mainData = objectToMap(rawFields.get("mainData"));
         formData.put(TITLE_FIELD_ID, title);
-        formData.put(APPLICANT_FIELD_ID, java.util.Collections.singletonList(request.getUserId()));
+        formData.put(APPLICANT_FIELD_ID, Collections.singletonList(request.getUserId()));
         formData.put(FORM_TITLE_FIELD_ID, title);
         putIfNotBlank(formData, COMPANY_FIELD_ID, mainData.get("gs"));
         putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(mainData.get("rq")));
@@ -145,9 +165,9 @@ public class Cpmp402YidaProcessHandler implements YidaProcessHandler {
         if (rowSource instanceof List) {
             sourceRows = (List<?>) rowSource;
         } else if (rowSource == null) {
-            sourceRows = java.util.Collections.emptyList();
+            sourceRows = Collections.emptyList();
         } else {
-            sourceRows = java.util.Collections.singletonList(rowSource);
+            sourceRows = Collections.singletonList(rowSource);
         }
 
         List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
@@ -156,7 +176,7 @@ public class Cpmp402YidaProcessHandler implements YidaProcessHandler {
             if (source.isEmpty()) {
                 continue;
             }
-            Map<String, Object> row = new java.util.LinkedHashMap<String, Object>();
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
             putIfNotBlank(row, DETAIL_PRICE_NO_FIELD_ID, source.get("hjdh"));
             putIfNotBlank(row, DETAIL_SUPPLIER_CODE_FIELD_ID, source.get("gysbh"));
             putIfNotBlank(row, DETAIL_SUPPLIER_NAME_FIELD_ID, source.get("gysmc"));
@@ -172,9 +192,22 @@ public class Cpmp402YidaProcessHandler implements YidaProcessHandler {
 
     private Map<String, Object> objectToMap(Object value) {
         if (value == null) {
-            return java.util.Collections.emptyMap();
+            return Collections.emptyMap();
+        }
+        if (value instanceof Map) {
+            return copyMap((Map<?, ?>) value);
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
         }
-        return JSON.parseObject(JSON.toJSONString(value), Map.class);
+        return result;
     }
 
     private Object firstNonBlank(Object first, Object second) {
@@ -223,7 +256,7 @@ public class Cpmp402YidaProcessHandler implements YidaProcessHandler {
     }
 
     private Map<String, Object> maskSensitive(YidaOperationParam parameter) {
-        Map<String, Object> body = JSON.parseObject(JSON.toJSONString(parameter), Map.class);
+        Map<String, Object> body = JSON.parseObject(JSON.toJSONString(parameter));
         if (body.containsKey("systemToken")) {
             body.put("systemToken", "******");
         }

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

@@ -0,0 +1,413 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.ErpYidaPushMainData;
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.yida.YidaFormPushHandler;
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.dingtalk.DDConf;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户放行审批 ERP 推送到宜搭流程的处理器。
+ */
+@Service
+public class CustomerReleaseFormPushHandler implements YidaFormPushHandler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(CustomerReleaseFormPushHandler.class);
+    private static final String START_PROCESS_ENDPOINT = "/v1.0/yida/processes/instances/start";
+    public static final String TYPE = "axmt140";
+    private static final String FORM_NAME = "customerRelease";
+    private static final String FORM_UUID = "FORM-3BA9FE18494F4FDD85FE6ABF7DE741BAE30C";
+    private static final String PROCESS_CODE = "TPROC--6TG66381N8G8Y7L9GBEEY7QVXTUY2LBBGZ7TMU";
+    private static final String DEFAULT_ORIGINATOR_USER_ID = "41603502201288023";
+
+    private static final String TITLE_FIELD_ID = "textField_mtv1tgg1";
+    private static final String APPLICANT_FIELD_ID = "employeeField_msh186rr";
+    private static final String COMPANY_FIELD_ID = "selectField_mtv7uxgd";
+    private static final String DEPARTMENT_FIELD_ID = "departmentSelectField_msh186ry";
+    private static final String APPLY_DATE_FIELD_ID = "dateField_msh186rs";
+    private static final String FORM_TITLE_FIELD_ID = "textField_mspcpz92";
+    private static final String RELEASE_NO_FIELD_ID = "textField_mt7zi4fy";
+    private static final String RELEASE_DESC_FIELD_ID = "textField_mt7zi4g0";
+    private static final String NO_DELIVERY_REASON_FIELD_ID = "textField_mt7zi4fz";
+
+    private static final String DETAIL_TABLE_FIELD_ID = "tableField_msh186sv";
+    private static final String DETAIL_SEQ_FIELD_ID = "textField_mt7zi4g1";
+    private static final String DETAIL_NOTICE_NO_FIELD_ID = "textField_mt7zi4g2";
+    private static final String DETAIL_NOTICE_DATE_FIELD_ID = "dateField_mt7zi4g3";
+    private static final String DETAIL_CUSTOMER_CODE_FIELD_ID = "textField_mt6ur4w1";
+    private static final String DETAIL_CUSTOMER_NAME_FIELD_ID = "textField_mt7zi4g4";
+    private static final String DETAIL_TAX_AMOUNT_FIELD_ID = "numberField_mt7zi4g5";
+    private static final String DETAIL_SITE_CREDIT_FIELD_ID = "numberField_mt7zi4g6";
+    private static final String DETAIL_GROUP_CREDIT_FIELD_ID = "numberField_mt7zi4g7";
+    private static final String DETAIL_SHORT_REMARK_FIELD_ID = "textField_mt6ur4w8";
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties applications;
+
+    /**
+     * 创建客户放行审批推送处理器。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     */
+    public CustomerReleaseFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                          DDConf dingTalkConfiguration,
+                                          YidaApplicationsProperties applications) {
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.applications = applications;
+    }
+
+    /**
+     * 返回客户放行审批支持的 ERP 推送类型。
+     *
+     * @return 支持的 type 列表
+     */
+    @Override
+    public List<String> getTypes() {
+        return Collections.singletonList(TYPE);
+    }
+
+    /**
+     * 将客户放行审批数据转换为宜搭流程表单并发起流程。
+     *
+     * @param request ERP 推送请求
+     * @return 宜搭流程实例 ID 或成功标识
+     */
+    @Override
+    public String push(ErpYidaPushRequest request) {
+        validateConfiguration();
+
+        String operatorUserId = resolveOperatorUserId(request);
+        String appType = requireText(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        String systemToken = requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+        Map<String, Object> formData = toYidaFormData(request, operatorUserId);
+        String title = requireText(toText(formData.get(TITLE_FIELD_ID)), "Yida process title is not configured");
+        Map<String, Object> body = new LinkedHashMap<String, Object>();
+        body.put("appType", appType);
+        body.put("systemToken", systemToken);
+        body.put("userId", operatorUserId);
+        body.put("processCode", PROCESS_CODE);
+        body.put("formUuid", FORM_UUID);
+        body.put("formDataJson", JSON.toJSONString(formData));
+        body.put("executeExpression", true);
+        body.put("noExecuteExpression", false);
+        body.put("title", title);
+        body.put("dataTitle", title);
+        LOGGER.info("Yida process start request endpoint={} formName={} body={}",
+                START_PROCESS_ENDPOINT, FORM_NAME, JSON.toJSONString(maskSensitive(body)));
+
+        DDR_New response;
+        try {
+            response = yidaProcessOpenApiClient.startProcess(body);
+        } catch (McException exception) {
+            throw exception;
+        } catch (Exception exception) {
+            LOGGER.error("Yida process start failed formName={} body={}",
+                    FORM_NAME, JSON.toJSONString(maskSensitive(body)), exception);
+            throw new McException("DINGTALK_YIDA_ERROR", "Yida process start failed: " + rootMessage(exception));
+        }
+        return extractProcessInstanceId(response);
+    }
+
+    private Map<String, Object> toYidaFormData(ErpYidaPushRequest request, String operatorUserId) {
+        Map<String, Object> main = objectToMap(request.getMainData());
+        putIfNotBlank(main, "bm", request.getBm());
+        putIfNotBlank(main, "gs", request.getGs());
+        putIfNotBlank(main, "tjr", request.getTjr());
+        Map<String, Object> detailWrapper = objectToMap(request.getDetailData());
+        List<Map<String, Object>> detailRows = toMapRows(firstNonBlank(detailWrapper.get("detailData1"), detailWrapper.get("detailData")));
+        String docNo = toText(value(main, "xmfxdocno"));
+        String title = toText(firstNonBlank(value(main, "title", "dataTitle"), docNo == null ? null : "customerRelease-" + docNo));
+
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        putIfNotBlank(formData, TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, APPLICANT_FIELD_ID, toStringList(operatorUserId, null));
+        putIfNotBlank(formData, COMPANY_FIELD_ID, firstNonBlank(value(main, "gs", "applyCompany", "company"), value(main, "xmfxent")));
+        putIfNotBlank(formData, DEPARTMENT_FIELD_ID, toStringList(value(main, "bm", "applyDepartment", "department", "deptId"), null));
+        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(firstNonBlank(value(main, "rq", "applyDate"), value(main, "xmfxdocdt"))));
+        putIfNotBlank(formData, FORM_TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, RELEASE_NO_FIELD_ID, value(main, "xmfxdocno"));
+        putIfNotBlank(formData, RELEASE_DESC_FIELD_ID, value(main, "xmfx003"));
+        putIfNotBlank(formData, NO_DELIVERY_REASON_FIELD_ID, value(main, "xmfxud001"));
+
+        List<Map<String, Object>> rows = toDetailRows(detailRows);
+        if (!rows.isEmpty()) {
+            formData.put(DETAIL_TABLE_FIELD_ID, rows);
+        }
+        copyYidaFieldValues(main, formData);
+        return formData;
+    }
+
+    private List<Map<String, Object>> toDetailRows(List<Map<String, Object>> sourceRows) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Map<String, Object> source : sourceRows) {
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfNotBlank(row, DETAIL_SEQ_FIELD_ID, value(source, "xmfyseq"));
+            putIfNotBlank(row, DETAIL_NOTICE_NO_FIELD_ID, value(source, "xmfy002"));
+            putIfNotBlank(row, DETAIL_NOTICE_DATE_FIELD_ID, toDateMillis(value(source, "xmfy003")));
+            putIfNotBlank(row, DETAIL_CUSTOMER_CODE_FIELD_ID, value(source, "xmfy004"));
+            putIfNotBlank(row, DETAIL_CUSTOMER_NAME_FIELD_ID, value(source, "pmaal004"));
+            putIfNotBlank(row, DETAIL_TAX_AMOUNT_FIELD_ID, toDecimal(value(source, "xmfy005")));
+            putIfNotBlank(row, DETAIL_SITE_CREDIT_FIELD_ID, toDecimal(value(source, "xmfy006")));
+            putIfNotBlank(row, DETAIL_GROUP_CREDIT_FIELD_ID, toDecimal(value(source, "xmfy008")));
+            putIfNotBlank(row, DETAIL_SHORT_REMARK_FIELD_ID, value(source, "xmfy007"));
+            copyYidaFieldValues(source, row);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<Map<String, Object>> toMapRows(Object value) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Object item : toList(value)) {
+            Map<String, Object> row = objectToMap(item);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<?> toList(Object value) {
+        if (value instanceof List) {
+            return (List<?>) value;
+        }
+        if (value instanceof Iterable) {
+            List<Object> result = new ArrayList<Object>();
+            for (Object item : (Iterable<?>) value) {
+                result.add(item);
+            }
+            return result;
+        }
+        return value == null ? Collections.emptyList() : Collections.singletonList(value);
+    }
+
+    private List<String> toStringList(Object value, String fallback) {
+        Object actualValue = isBlank(value) ? fallback : value;
+        if (actualValue == null) {
+            return Collections.emptyList();
+        }
+        if (actualValue instanceof Iterable) {
+            List<String> result = new ArrayList<String>();
+            for (Object item : (Iterable<?>) actualValue) {
+                if (!isBlank(item)) {
+                    result.add(String.valueOf(item).trim());
+                }
+            }
+            return result;
+        }
+        return Collections.singletonList(String.valueOf(actualValue).trim());
+    }
+
+    private BigDecimal toDecimal(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        if (value instanceof Number) {
+            return new BigDecimal(String.valueOf(value));
+        }
+        return new BigDecimal(String.valueOf(value).trim());
+    }
+
+    private Long toDateMillis(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.matches("\\d+")) {
+            return Long.valueOf(text);
+        }
+        return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+    }
+
+    private Object value(Map<String, Object> source, String firstKey, String... otherKeys) {
+        Object value = source.get(firstKey);
+        if (!isBlank(value)) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = source.get(key);
+            if (!isBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Object firstNonBlank(Object first, Object second) {
+        return isBlank(first) ? second : first;
+    }
+
+    private void putIfNotBlank(Map<String, Object> target, String fieldId, Object value) {
+        if (!isBlank(value)) {
+            target.put(fieldId, value);
+        }
+    }
+
+    private void copyYidaFieldValues(Map<String, Object> source, Map<String, Object> target) {
+        for (Map.Entry<String, Object> entry : source.entrySet()) {
+            if (entry.getKey() != null && entry.getKey().contains("Field_") && !isBlank(entry.getValue())) {
+                target.put(entry.getKey(), entry.getValue());
+            }
+        }
+    }
+
+    private Map<String, Object> objectToMap(Object value) {
+        if (value == null) {
+            return Collections.emptyMap();
+        }
+        if (value instanceof Map) {
+            return copyMap((Map<?, ?>) value);
+        }
+        if (value instanceof ErpYidaPushMainData) {
+            ErpYidaPushMainData mainData = (ErpYidaPushMainData) value;
+            Map<String, Object> result = new LinkedHashMap<String, Object>();
+            putIfNotBlank(result, "rq", mainData.getRq());
+            putIfNotBlank(result, "bm", mainData.getBm());
+            putIfNotBlank(result, "tjr", mainData.getTjr());
+            putIfNotBlank(result, "gs", mainData.getGs());
+            putIfNotBlank(result, "t100id", mainData.getT100id());
+            putIfNotBlank(result, "ent", mainData.getEnt());
+            putIfNotBlank(result, "t100bm", mainData.getT100bm());
+            result.putAll(mainData.getExtraFields());
+            return result;
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
+    }
+
+    private boolean isBlank(Object value) {
+        if (value == null) {
+            return true;
+        }
+        if (value instanceof Iterable) {
+            return !((Iterable<?>) value).iterator().hasNext();
+        }
+        if (value instanceof Map) {
+            return ((Map<?, ?>) value).isEmpty();
+        }
+        return String.valueOf(value).trim().isEmpty();
+    }
+
+    private String resolveOperatorUserId(ErpYidaPushRequest request) {
+        if (!isBlank(request.getOriginatorUserId())) {
+            return request.getOriginatorUserId().trim();
+        }
+        Map<String, Object> main = objectToMap(request.getMainData());
+        Object operator = firstNonBlank(value(main, "originatorUserId", "userId"), value(main, "applicant"));
+        return String.valueOf(isBlank(operator) ? DEFAULT_ORIGINATOR_USER_ID : firstItem(operator)).trim();
+    }
+
+    private Object firstItem(Object value) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                if (!isBlank(item)) {
+                    return item;
+                }
+            }
+            return null;
+        }
+        return value;
+    }
+
+    private String toText(Object value) {
+        return isBlank(value) ? null : String.valueOf(value);
+    }
+
+    private Map<String, Object> maskSensitive(Map<String, Object> source) {
+        Map<String, Object> body = new LinkedHashMap<String, Object>(source);
+        if (body.containsKey("systemToken")) {
+            body.put("systemToken", "******");
+        }
+        return body;
+    }
+
+    private String extractProcessInstanceId(DDR_New response) {
+        if (response == null) {
+            throw new McException("YIDA_NULL_RESPONSE", "Yida process start failed: empty response");
+        }
+        LOGGER.info("Yida process start response formName={} success={} code={} result={} instanceId={} formInstId={}",
+                FORM_NAME, response.isSuccess(), response.getCode(), response.getResult(), response.getInstanceId(), response.getFormInstId());
+
+        Object result = firstNonBlank(firstNonBlank(response.getResult(), response.getInstanceId()), response.getFormInstId());
+        if (!isBlank(result)) {
+            return String.valueOf(result);
+        }
+        if (response.isSuccess()) {
+            return "SUCCESS";
+        }
+        String code = defaultText(response.getCode(), "YIDA_ERROR");
+        String message = defaultText(response.getMessage(), "Yida process start failed: no process instance id");
+        throw new McException(code, "Yida API [startProcess] failed: " + message);
+    }
+
+    private String defaultText(String value, String fallback) {
+        return value == null || value.trim().isEmpty() ? fallback : value.trim();
+    }
+
+    private String rootMessage(Throwable exception) {
+        Throwable current = exception;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        String message = current.getMessage();
+        if (message == null || message.trim().isEmpty()) {
+            message = exception.getMessage();
+        }
+        if (message == null || message.trim().isEmpty()) {
+            message = current.getClass().getSimpleName();
+        }
+        return message.trim();
+    }
+
+    private void validateConfiguration() {
+        require(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        require(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        require(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        require(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+
+    private String requireText(String value, String message) {
+        require(value, message);
+        return value.trim();
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

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

@@ -18,10 +18,21 @@ public class DingTalkYidaProcessOpenApiClient implements YidaProcessOpenApiClien
 
     private final DDClient dingTalkClient;
 
+    /**
+     * 创建宜搭流程 OpenAPI 客户端。
+     *
+     * @param dingTalkClient 钉钉开放平台客户端
+     */
     public DingTalkYidaProcessOpenApiClient(DDClient dingTalkClient) {
         this.dingTalkClient = dingTalkClient;
     }
 
+    /**
+     * 使用钉钉访问令牌调用宜搭流程发起接口。
+     *
+     * @param body 宜搭流程发起请求体
+     * @return 钉钉 OpenAPI 原始响应
+     */
     @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);

+ 29 - 8
mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImpl.java

@@ -107,12 +107,21 @@ public class ErpYidaPushServiceImpl implements ErpYidaPushService {
         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)) {
+        if (!hasDetailDataRows((Map<?, ?>) detailData)) {
             throw new IllegalArgumentException("ERP Yida push detailData.detailData must be an array");
         }
     }
 
+    private boolean hasDetailDataRows(Map<?, ?> detailData) {
+        for (Map.Entry<?, ?> entry : detailData.entrySet()) {
+            Object key = entry.getKey();
+            if (key != null && String.valueOf(key).matches("detailData\\d*") && entry.getValue() instanceof List) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private YidaProcessStartRequest toProcessStartRequest(ErpYidaPushRequest request, String originatorUserId) {
         YidaProcessStartRequest yidaRequest = new YidaProcessStartRequest();
         yidaRequest.setType(request.getType());
@@ -176,26 +185,38 @@ public class ErpYidaPushServiceImpl implements ErpYidaPushService {
 
     private void applyMainDataDefaults(ErpYidaPushRequest request, String originatorUserId) {
         ErpYidaPushMainData mainData = request.getMainData();
-        Map<String, Object> wrapper = new LinkedHashMap<String, Object>((Map<String, Object>) request.getDetailData());
+        Map<String, Object> wrapper = copyMap((Map<?, ?>) request.getDetailData());
 
-        Object rows = wrapper.get("detailData");
-        if (rows instanceof List) {
+        for (Map.Entry<String, Object> entry : new ArrayList<Map.Entry<String, Object>>(wrapper.entrySet())) {
+            if (entry.getKey() == null || !entry.getKey().matches("detailData\\d*") || !(entry.getValue() instanceof List)) {
+                continue;
+            }
             List<Object> normalizedRows = new ArrayList<Object>();
-            for (Object row : (List<?>) rows) {
+            for (Object row : (List<?>) entry.getValue()) {
                 if (row instanceof Map) {
-                    Map<String, Object> normalizedRow = new LinkedHashMap<String, Object>((Map<String, Object>) row);
+                    Map<String, Object> normalizedRow = copyMap((Map<?, ?>) row);
                     applyMainDataDefaults(normalizedRow, mainData, originatorUserId);
                     normalizedRows.add(normalizedRow);
                 } else {
                     normalizedRows.add(row);
                 }
             }
-            wrapper.put("detailData", normalizedRows);
+            wrapper.put(entry.getKey(), normalizedRows);
         }
 
         request.setDetailData(wrapper);
     }
 
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
+    }
+
     private void applyMainDataDefaults(Map<String, Object> target, ErpYidaPushMainData mainData, String originatorUserId) {
         putIfMissing(target, "originatorUserId", originatorUserId);
         putIfMissing(target, "userId", originatorUserId);

+ 35 - 4
mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/MingleiDocumentServiceImpl.java

@@ -23,6 +23,9 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+/**
+ * 明磊主数据宜搭表单查询服务实现。
+ */
 @Service
 public class MingleiDocumentServiceImpl implements MingleiDocumentService {
 
@@ -50,6 +53,16 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
     private final YidaApplicationsProperties.ApplicationCredentials masterDataConfiguration;
     private final ObjectMapper objectMapper;
 
+    /**
+     * 创建明磊主数据表单查询服务。
+     *
+     * @param yidaClient 主数据宜搭客户端
+     * @param dingTalkClient 钉钉基础客户端
+     * @param dingTalkContactsClient 钉钉通讯录客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     * @param objectMapper JSON 序列化组件
+     */
     public MingleiDocumentServiceImpl(@Qualifier("masterDataYidaClient") YDClient yidaClient,
                                       DDClient dingTalkClient,
                                       DDClient_Contacts dingTalkContactsClient,
@@ -115,18 +128,36 @@ public class MingleiDocumentServiceImpl implements MingleiDocumentService {
      */
     @Override
     public List<String> listDepartmentUserIds(long departmentId, int type) {
+        return listDepartmentUserIds(Collections.singletonList(departmentId), type);
+    }
+
+    @Override
+    public List<String> listDepartmentUserIds(List<Long> requestedDepartmentIdList, int type) {
         String accessToken = dingTalkClient.getAccessToken();
         require(accessToken, "Unable to obtain DingTalk access token");
 
+        Set<Long> requestedDepartmentIds = new LinkedHashSet<Long>();
+        if (requestedDepartmentIdList != null) {
+            for (Long departmentId : requestedDepartmentIdList) {
+                if (departmentId != null) {
+                    requestedDepartmentIds.add(departmentId);
+                }
+            }
+        }
+
         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);
+            for (Long departmentId : requestedDepartmentIds) {
+                departmentIds.add(departmentId);
+                List<Long> children = dingTalkContactsClient.getDepartmentId_all(accessToken, false, departmentId.longValue());
+                if (children != null) {
+                    departmentIds.addAll(children);
+                }
             }
+        } else {
+            departmentIds.addAll(requestedDepartmentIds);
         }
 
         Set<String> userIds = new LinkedHashSet<String>();

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

@@ -0,0 +1,437 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.ErpYidaPushMainData;
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.yida.YidaFormPushHandler;
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.dingtalk.DDConf;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 杂收发料单 ERP 推送到宜搭流程的处理器。
+ */
+@Service
+public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(MiscReceiptIssueFormPushHandler.class);
+    private static final String START_PROCESS_ENDPOINT = "/v1.0/yida/processes/instances/start";
+    public static final String TYPE = "cint301";
+    private static final String FORM_NAME = "miscReceiptIssue";
+    private static final String FORM_UUID = "FORM-4B08ADABCDAA45788C043729A855E03ED76G";
+    private static final String PROCESS_CODE = "TPROC--MQD66371C1N84FPSJZO4W79IN4BW2AE7I08TM0";
+    private static final String DEFAULT_ORIGINATOR_USER_ID = "41603502201288023";
+
+    private static final String TITLE_FIELD_ID = "textField_msh27unu";
+    private static final String APPLICANT_FIELD_ID = "employeeField_msh186rr";
+    private static final String COMPANY_FIELD_ID = "selectField_mso1em48";
+    private static final String DEPARTMENT_FIELD_ID = "departmentSelectField_msh186ry";
+    private static final String APPLY_DATE_FIELD_ID = "dateField_msh186rs";
+    private static final String FORM_TITLE_FIELD_ID = "textField_mspcpz92";
+    private static final String DATA_SOURCE_FIELD_ID = "textField_mt7zi4fy";
+    private static final String APPLICANT_NO_FIELD_ID = "textField_mt7zi4g0";
+    private static final String DEPARTMENT_NO_FIELD_ID = "textField_mt80nq6s";
+    private static final String REASON_CODE_FIELD_ID = "textField_mt80nq6t";
+    private static final String REASON_DESC_FIELD_ID = "textField_mt80nq6v";
+    private static final String REMARK_FIELD_ID = "textField_mt7zi4fz";
+
+    private static final String DETAIL_TABLE_FIELD_ID = "tableField_msh186sv";
+    private static final String DETAIL_SEQ_FIELD_ID = "textField_mt7zi4g1";
+    private static final String DETAIL_ORDER_NO_FIELD_ID = "textField_mt80nq6y";
+    private static final String DETAIL_ORDER_SEQ_FIELD_ID = "textField_mt80nq6z";
+    private static final String DETAIL_ITEM_CODE_FIELD_ID = "textField_mt80nq70";
+    private static final String DETAIL_ITEM_NAME_FIELD_ID = "textField_mt80nq71";
+    private static final String DETAIL_SPEC_FIELD_ID = "textField_mt80nq72";
+    private static final String DETAIL_PRODUCT_FEATURE_FIELD_ID = "textField_mt80nq73";
+    private static final String DETAIL_WAREHOUSE_FIELD_ID = "textField_mt80nq74";
+    private static final String DETAIL_LOCATION_FIELD_ID = "textField_mt80nq75";
+    private static final String DETAIL_LOT_NO_FIELD_ID = "textField_mt7zi4g2";
+    private static final String DETAIL_STOCK_FEATURE_FIELD_ID = "textField_mt80nq76";
+    private static final String DETAIL_QUANTITY_FIELD_ID = "numberField_mt80nq77";
+    private static final String DETAIL_UNIT_FIELD_ID = "textField_mt6ur4w1";
+    private static final String DETAIL_REASON_CODE_FIELD_ID = "textField_mt7zi4g4";
+    private static final String DETAIL_REASON_DESC_FIELD_ID = "textField_mt80nq79";
+    private static final String DETAIL_PROJECT_NO_FIELD_ID = "textField_mt6ur4w8";
+    private static final String DETAIL_PROJECT_DESC_FIELD_ID = "textField_mt80nq7a";
+    private static final String DETAIL_SOURCE_DOC_NO_FIELD_ID = "textField_mt80nq7b";
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties applications;
+
+    /**
+     * 创建杂收发料单推送处理器。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     */
+    public MiscReceiptIssueFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                           DDConf dingTalkConfiguration,
+                                           YidaApplicationsProperties applications) {
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.applications = applications;
+    }
+
+    /**
+     * 返回杂收发料单支持的 ERP 推送类型。
+     *
+     * @return 支持的 type 列表
+     */
+    @Override
+    public List<String> getTypes() {
+        return Collections.singletonList(TYPE);
+    }
+
+    /**
+     * 将杂收发料单数据转换为宜搭流程表单并发起流程。
+     *
+     * @param request ERP 推送请求
+     * @return 宜搭流程实例 ID 或成功标识
+     */
+    @Override
+    public String push(ErpYidaPushRequest request) {
+        validateConfiguration();
+
+        String operatorUserId = resolveOperatorUserId(request);
+        String appType = requireText(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        String systemToken = requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+        Map<String, Object> formData = toYidaFormData(request, operatorUserId);
+        String title = requireText(toText(formData.get(TITLE_FIELD_ID)), "Yida process title is not configured");
+        Map<String, Object> body = new LinkedHashMap<String, Object>();
+        body.put("appType", appType);
+        body.put("systemToken", systemToken);
+        body.put("userId", operatorUserId);
+        body.put("processCode", PROCESS_CODE);
+        body.put("formUuid", FORM_UUID);
+        body.put("formDataJson", JSON.toJSONString(formData));
+        body.put("executeExpression", true);
+        body.put("noExecuteExpression", false);
+        body.put("title", title);
+        body.put("dataTitle", title);
+        LOGGER.info("Yida process start request endpoint={} formName={} body={}",
+                START_PROCESS_ENDPOINT, FORM_NAME, JSON.toJSONString(maskSensitive(body)));
+
+        DDR_New response;
+        try {
+            response = yidaProcessOpenApiClient.startProcess(body);
+        } catch (McException exception) {
+            throw exception;
+        } catch (Exception exception) {
+            LOGGER.error("Yida process start failed formName={} body={}",
+                    FORM_NAME, JSON.toJSONString(maskSensitive(body)), exception);
+            throw new McException("DINGTALK_YIDA_ERROR", "Yida process start failed: " + rootMessage(exception));
+        }
+        return extractProcessInstanceId(response);
+    }
+
+    private Map<String, Object> toYidaFormData(ErpYidaPushRequest request, String operatorUserId) {
+        Map<String, Object> main = objectToMap(request.getMainData());
+        putIfNotBlank(main, "bm", request.getBm());
+        putIfNotBlank(main, "gs", request.getGs());
+        putIfNotBlank(main, "tjr", request.getTjr());
+        Map<String, Object> detailWrapper = objectToMap(request.getDetailData());
+        List<Map<String, Object>> detailRows = toMapRows(detailWrapper.get("detailData1"));
+        String docNo = toText(value(main, "inbadocno"));
+        String title = toText(firstNonBlank(value(main, "title", "dataTitle"), docNo == null ? null : "miscReceiptIssue-" + docNo));
+
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        putIfNotBlank(formData, TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, APPLICANT_FIELD_ID, toStringList(operatorUserId, null));
+        putIfNotBlank(formData, COMPANY_FIELD_ID, firstNonBlank(value(main, "gs", "applyCompany", "company"), value(main, "inbaent")));
+        putIfNotBlank(formData, DEPARTMENT_FIELD_ID, toStringList(value(main, "bm", "applyDepartment", "department", "deptId"), null));
+        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(firstNonBlank(value(main, "rq", "applyDate"), value(main, "inbadocdt"))));
+        putIfNotBlank(formData, FORM_TITLE_FIELD_ID, title);
+        putIfNotBlank(formData, DATA_SOURCE_FIELD_ID, value(main, "inba006"));
+        putIfNotBlank(formData, APPLICANT_NO_FIELD_ID, value(main, "inba003"));
+        putIfNotBlank(formData, DEPARTMENT_NO_FIELD_ID, value(main, "inba004"));
+        putIfNotBlank(formData, REASON_CODE_FIELD_ID, value(main, "inba007"));
+        putIfNotBlank(formData, REASON_DESC_FIELD_ID, value(main, "oocql004"));
+        putIfNotBlank(formData, REMARK_FIELD_ID, value(main, "inba008"));
+
+        List<Map<String, Object>> rows = toDetailRows(detailRows);
+        if (!rows.isEmpty()) {
+            formData.put(DETAIL_TABLE_FIELD_ID, rows);
+        }
+        copyYidaFieldValues(main, formData);
+        return formData;
+    }
+
+    private List<Map<String, Object>> toDetailRows(List<Map<String, Object>> sourceRows) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Map<String, Object> source : sourceRows) {
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfNotBlank(row, DETAIL_SEQ_FIELD_ID, value(source, "inbbseq"));
+            putIfNotBlank(row, DETAIL_ORDER_NO_FIELD_ID, value(source, "inbbua001"));
+            putIfNotBlank(row, DETAIL_ORDER_SEQ_FIELD_ID, value(source, "inbbua002"));
+            putIfNotBlank(row, DETAIL_ITEM_CODE_FIELD_ID, value(source, "inbb001"));
+            putIfNotBlank(row, DETAIL_ITEM_NAME_FIELD_ID, value(source, "imaal003"));
+            putIfNotBlank(row, DETAIL_SPEC_FIELD_ID, value(source, "imaal004"));
+            putIfNotBlank(row, DETAIL_PRODUCT_FEATURE_FIELD_ID, value(source, "inbb002"));
+            putIfNotBlank(row, DETAIL_WAREHOUSE_FIELD_ID, value(source, "inbb007"));
+            putIfNotBlank(row, DETAIL_LOCATION_FIELD_ID, value(source, "inbb008"));
+            putIfNotBlank(row, DETAIL_LOT_NO_FIELD_ID, value(source, "inbb009"));
+            putIfNotBlank(row, DETAIL_STOCK_FEATURE_FIELD_ID, value(source, "inbb003"));
+            putIfNotBlank(row, DETAIL_QUANTITY_FIELD_ID, toDecimal(value(source, "inbb011")));
+            putIfNotBlank(row, DETAIL_UNIT_FIELD_ID, value(source, "inbb010"));
+            putIfNotBlank(row, DETAIL_REASON_CODE_FIELD_ID, value(source, "inbb016"));
+            putIfNotBlank(row, DETAIL_REASON_DESC_FIELD_ID, value(source, "oocql004"));
+            putIfNotBlank(row, DETAIL_PROJECT_NO_FIELD_ID, value(source, "inbb023"));
+            putIfNotBlank(row, DETAIL_PROJECT_DESC_FIELD_ID, value(source, "pjbal003"));
+            putIfNotBlank(row, DETAIL_SOURCE_DOC_NO_FIELD_ID, value(source, "inbb017"));
+            copyYidaFieldValues(source, row);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<Map<String, Object>> toMapRows(Object value) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (Object item : toList(value)) {
+            Map<String, Object> row = objectToMap(item);
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<?> toList(Object value) {
+        if (value instanceof List) {
+            return (List<?>) value;
+        }
+        if (value instanceof Iterable) {
+            List<Object> result = new ArrayList<Object>();
+            for (Object item : (Iterable<?>) value) {
+                result.add(item);
+            }
+            return result;
+        }
+        return value == null ? Collections.emptyList() : Collections.singletonList(value);
+    }
+
+    private List<String> toStringList(Object value, String fallback) {
+        Object actualValue = isBlank(value) ? fallback : value;
+        if (actualValue == null) {
+            return Collections.emptyList();
+        }
+        if (actualValue instanceof Iterable) {
+            List<String> result = new ArrayList<String>();
+            for (Object item : (Iterable<?>) actualValue) {
+                if (!isBlank(item)) {
+                    result.add(String.valueOf(item).trim());
+                }
+            }
+            return result;
+        }
+        return Collections.singletonList(String.valueOf(actualValue).trim());
+    }
+
+    private BigDecimal toDecimal(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        if (value instanceof Number) {
+            return new BigDecimal(String.valueOf(value));
+        }
+        return new BigDecimal(String.valueOf(value).trim());
+    }
+
+    private Long toDateMillis(Object value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.matches("\\d+")) {
+            return Long.valueOf(text);
+        }
+        return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+    }
+
+    private Object value(Map<String, Object> source, String firstKey, String... otherKeys) {
+        Object value = source.get(firstKey);
+        if (!isBlank(value)) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = source.get(key);
+            if (!isBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Object firstNonBlank(Object first, Object second) {
+        return isBlank(first) ? second : first;
+    }
+
+    private void putIfNotBlank(Map<String, Object> target, String fieldId, Object value) {
+        if (!isBlank(value)) {
+            target.put(fieldId, value);
+        }
+    }
+
+    private void copyYidaFieldValues(Map<String, Object> source, Map<String, Object> target) {
+        for (Map.Entry<String, Object> entry : source.entrySet()) {
+            if (entry.getKey() != null && entry.getKey().contains("Field_") && !isBlank(entry.getValue())) {
+                target.put(entry.getKey(), entry.getValue());
+            }
+        }
+    }
+
+    private Map<String, Object> objectToMap(Object value) {
+        if (value == null) {
+            return Collections.emptyMap();
+        }
+        if (value instanceof Map) {
+            return copyMap((Map<?, ?>) value);
+        }
+        if (value instanceof ErpYidaPushMainData) {
+            ErpYidaPushMainData mainData = (ErpYidaPushMainData) value;
+            Map<String, Object> result = new LinkedHashMap<String, Object>();
+            putIfNotBlank(result, "rq", mainData.getRq());
+            putIfNotBlank(result, "bm", mainData.getBm());
+            putIfNotBlank(result, "tjr", mainData.getTjr());
+            putIfNotBlank(result, "gs", mainData.getGs());
+            putIfNotBlank(result, "t100id", mainData.getT100id());
+            putIfNotBlank(result, "ent", mainData.getEnt());
+            putIfNotBlank(result, "t100bm", mainData.getT100bm());
+            result.putAll(mainData.getExtraFields());
+            return result;
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
+        }
+        return result;
+    }
+
+    private boolean isBlank(Object value) {
+        if (value == null) {
+            return true;
+        }
+        if (value instanceof Iterable) {
+            return !((Iterable<?>) value).iterator().hasNext();
+        }
+        if (value instanceof Map) {
+            return ((Map<?, ?>) value).isEmpty();
+        }
+        return String.valueOf(value).trim().isEmpty();
+    }
+
+    private String resolveOperatorUserId(ErpYidaPushRequest request) {
+        if (!isBlank(request.getOriginatorUserId())) {
+            return request.getOriginatorUserId().trim();
+        }
+        Map<String, Object> main = objectToMap(request.getMainData());
+        Object operator = firstNonBlank(value(main, "originatorUserId", "userId"), value(main, "applicant"));
+        return String.valueOf(isBlank(operator) ? DEFAULT_ORIGINATOR_USER_ID : firstItem(operator)).trim();
+    }
+
+    private Object firstItem(Object value) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                if (!isBlank(item)) {
+                    return item;
+                }
+            }
+            return null;
+        }
+        return value;
+    }
+
+    private String toText(Object value) {
+        return isBlank(value) ? null : String.valueOf(value);
+    }
+
+    private Map<String, Object> maskSensitive(Map<String, Object> source) {
+        Map<String, Object> body = new LinkedHashMap<String, Object>(source);
+        if (body.containsKey("systemToken")) {
+            body.put("systemToken", "******");
+        }
+        return body;
+    }
+
+    private String extractProcessInstanceId(DDR_New response) {
+        if (response == null) {
+            throw new McException("YIDA_NULL_RESPONSE", "Yida process start failed: empty response");
+        }
+        LOGGER.info("Yida process start response formName={} success={} code={} result={} instanceId={} formInstId={}",
+                FORM_NAME, response.isSuccess(), response.getCode(), response.getResult(), response.getInstanceId(), response.getFormInstId());
+
+        Object result = firstNonBlank(firstNonBlank(response.getResult(), response.getInstanceId()), response.getFormInstId());
+        if (!isBlank(result)) {
+            return String.valueOf(result);
+        }
+        if (response.isSuccess()) {
+            return "SUCCESS";
+        }
+        String code = defaultText(response.getCode(), "YIDA_ERROR");
+        String message = defaultText(response.getMessage(), "Yida process start failed: no process instance id");
+        throw new McException(code, "Yida API [startProcess] failed: " + message);
+    }
+
+    private String defaultText(String value, String fallback) {
+        return value == null || value.trim().isEmpty() ? fallback : value.trim();
+    }
+
+    private String rootMessage(Throwable exception) {
+        Throwable current = exception;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        String message = current.getMessage();
+        if (message == null || message.trim().isEmpty()) {
+            message = exception.getMessage();
+        }
+        if (message == null || message.trim().isEmpty()) {
+            message = current.getClass().getSimpleName();
+        }
+        return message.trim();
+    }
+
+    private void validateConfiguration() {
+        require(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        require(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        require(applications.getCoordinationOffice().getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        require(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+
+    private String requireText(String value, String message) {
+        require(value, message);
+        return value.trim();
+    }
+
+    private void require(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException(message);
+        }
+    }
+}

+ 32 - 6
mjava-minglei/src/main/java/com/malk/minglei/service/impl/yida/MlWorkOrderCraftPriceFormPushHandler.java

@@ -17,7 +17,6 @@ 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;
@@ -31,9 +30,9 @@ 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";
+    public static final String TYPE = FORM_UUID;
     private static final String PROCESS_CODE = "TPROC--WG966BA1VWF8Q1K4OGF8H9KW47RR3ZREH0YSM2";
     private static final String DEFAULT_ORIGINATOR_USER_ID = "41603502201288023";
 
@@ -76,6 +75,13 @@ public class MlWorkOrderCraftPriceFormPushHandler implements YidaFormPushHandler
     private final DDConf dingTalkConfiguration;
     private final YidaApplicationsProperties applications;
 
+    /**
+     * 创建工单工艺工价审定推送处理器。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     */
     public MlWorkOrderCraftPriceFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
                                                 DDConf dingTalkConfiguration,
                                                 YidaApplicationsProperties applications) {
@@ -84,11 +90,22 @@ public class MlWorkOrderCraftPriceFormPushHandler implements YidaFormPushHandler
         this.applications = applications;
     }
 
+    /**
+     * 返回工单工艺工价审定支持的 ERP 推送类型。
+     *
+     * @return 支持的 type 列表
+     */
     @Override
     public List<String> getTypes() {
-        return Arrays.asList(TYPE, "work_order_craft_price", FORM_NAME, FORM_UUID);
+        return Collections.singletonList(TYPE);
     }
 
+    /**
+     * 将工单工艺工价审定数据转换为宜搭流程表单并发起流程。
+     *
+     * @param request ERP 推送请求
+     * @return 宜搭流程实例 ID 或成功标识
+     */
     @Override
     public String push(ErpYidaPushRequest request) {
         validateConfiguration();
@@ -340,10 +357,19 @@ public class MlWorkOrderCraftPriceFormPushHandler implements YidaFormPushHandler
             return Collections.emptyMap();
         }
         if (value instanceof Map) {
-            Map result = (Map) value;
-            return result;
+            return copyMap((Map<?, ?>) value);
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private Map<String, Object> copyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<?, ?> entry : source.entrySet()) {
+            if (entry.getKey() != null) {
+                result.put(String.valueOf(entry.getKey()), entry.getValue());
+            }
         }
-        return JSON.parseObject(JSON.toJSONString(value), Map.class);
+        return result;
     }
 
     private Map<String, Object> toDetailRecord(Object detailData) {

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

@@ -19,6 +19,11 @@ public class YidaProcessServiceImpl implements YidaProcessService {
 
     private final Map<String, YidaProcessHandler> handlers;
 
+    /**
+     * 创建宜搭流程分发服务。
+     *
+     * @param handlers 所有宜搭流程处理器
+     */
     public YidaProcessServiceImpl(List<YidaProcessHandler> handlers) {
         Map<String, YidaProcessHandler> handlerMap = new LinkedHashMap<String, YidaProcessHandler>();
         for (YidaProcessHandler handler : handlers) {

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

@@ -5,6 +5,9 @@ import com.malk.minglei.dto.MingleiDocumentQueryRequest;
 import java.util.List;
 import java.util.Map;
 
+/**
+ * 明磊宜搭普通表单单据查询服务。
+ */
 public interface MingleiDocumentService {
 
     /**
@@ -32,4 +35,6 @@ public interface MingleiDocumentService {
      * @return 去重后的钉钉 userid 列表
      */
     List<String> listDepartmentUserIds(long departmentId, int type);
+
+    List<String> listDepartmentUserIds(List<Long> departmentIds, int type);
 }

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

@@ -9,7 +9,18 @@ import java.util.List;
  */
 public interface YidaFormPushHandler {
 
+    /**
+     * 返回当前处理器支持的 ERP 推送类型。
+     *
+     * @return 支持的 type 列表
+     */
     List<String> getTypes();
 
+    /**
+     * 将 ERP 推送请求转换并写入对应宜搭表单。
+     *
+     * @param request ERP 推送请求
+     * @return 宜搭返回的数据实例 ID 或处理结果
+     */
     String push(ErpYidaPushRequest request);
 }

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

@@ -9,5 +9,11 @@ import java.util.Map;
  */
 public interface YidaProcessOpenApiClient {
 
+    /**
+     * 调用宜搭 OpenAPI 发起流程实例。
+     *
+     * @param body 宜搭流程发起请求体
+     * @return 钉钉 OpenAPI 原始响应
+     */
     DDR_New startProcess(Map<String, Object> body);
 }

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

@@ -2,6 +2,9 @@ package com.malk.minglei.service.yida;
 
 import com.malk.minglei.dto.YidaProcessStartRequest;
 
+/**
+ * 宜搭流程实例发起服务。
+ */
 public interface YidaProcessService {
 
     /**

+ 11 - 9
mjava-minglei/src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java

@@ -48,6 +48,7 @@ class EmployeeRosterServiceImplTest {
     }
 
     @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
     void syncCollectsDepartmentUsersAndUpsertsRosterRecords() {
         DDClient dingTalkClient = mock(DDClient.class);
         DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
@@ -109,6 +110,7 @@ class EmployeeRosterServiceImplTest {
     }
 
     @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
     void syncKeepsJobTitleAndJobGradeNullWhenRosterFieldsAreMissing() {
         DDClient dingTalkClient = mock(DDClient.class);
         DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
@@ -151,7 +153,7 @@ class EmployeeRosterServiceImplTest {
         assertNull(records.get(0).getJobGradeId());
     }
 
-    private static Map user(String userId, String name, String jobNumber) {
+    private static Map<String, Object> user(String userId, String name, String jobNumber) {
         Map<String, Object> user = new LinkedHashMap<String, Object>();
         user.put("userid", userId);
         user.put("name", name);
@@ -160,32 +162,32 @@ class EmployeeRosterServiceImplTest {
         return user;
     }
 
-    private static Map userWithTitle(String userId, String name, String jobNumber, String title) {
-        Map user = user(userId, name, jobNumber);
+    private static Map<String, Object> userWithTitle(String userId, String name, String jobNumber, String title) {
+        Map<String, Object> user = user(userId, name, jobNumber);
         user.put("title", title);
         return user;
     }
 
-    private static Map mapOf(String key, Object value) {
+    private static Map<String, Object> mapOf(String key, Object value) {
         Map<String, Object> map = new LinkedHashMap<String, Object>();
         map.put(key, value);
         return map;
     }
 
-    private static Map mapOf(String key1, Object value1, String key2, Object value2) {
+    private static Map<String, Object> mapOf(String key1, Object value1, String key2, Object value2) {
         Map<String, Object> map = mapOf(key1, value1);
         map.put(key2, value2);
         return map;
     }
 
-    private static Map field(String fieldCode, String fieldName) {
+    private static Map<String, Object> field(String fieldCode, String fieldName) {
         Map<String, Object> field = new LinkedHashMap<String, Object>();
         field.put("field_code", fieldCode);
         field.put("field_name", fieldName);
         return field;
     }
 
-    private static Map roster(String userId, String jobTitle, String jobGrade, String department, String firstDepartment) {
+    private static Map<String, Object> roster(String userId, String jobTitle, String jobGrade, String department, String firstDepartment) {
         return mapOf("userid", userId, "field_data_list", Arrays.asList(
                 rosterField("sys00-jobTitle", jobTitle, "10040"),
                 rosterField("sys00-jobGrade", jobGrade, "10010"),
@@ -193,11 +195,11 @@ class EmployeeRosterServiceImplTest {
                 rosterField("sys00-firstDepartment", firstDepartment)));
     }
 
-    private static Map rosterField(String fieldCode, String value) {
+    private static Map<String, Object> rosterField(String fieldCode, String value) {
         return rosterField(fieldCode, value, value);
     }
 
-    private static Map rosterField(String fieldCode, String label, String value) {
+    private static Map<String, Object> rosterField(String fieldCode, String label, String value) {
         Map<String, Object> field = new LinkedHashMap<String, Object>();
         field.put("field_code", fieldCode);
         field.put("field_value_list", Collections.singletonList(mapOf("label", label, "value", value)));

+ 43 - 28
mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandlerTest.java

@@ -23,6 +23,7 @@ import static org.mockito.Mockito.when;
 class ApproveOrderFormPushHandlerTest {
 
     @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
     void pushMapsCustomerApproveOrderPayloadToYidaFieldIds() {
         YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
         DDR_New response = new DDR_New();
@@ -51,26 +52,42 @@ class ApproveOrderFormPushHandlerTest {
         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> deliverySourceRow = new LinkedHashMap<String, Object>();
+        deliverySourceRow.put("xmdcent", "87");
+        deliverySourceRow.put("xmdcdocno", "SO-001");
+        deliverySourceRow.put("xmdcseq", "10");
+        deliverySourceRow.put("xmdc001", "ITEM-001");
+        deliverySourceRow.put("xmdc002", "FEATURE-001");
+        deliverySourceRow.put("imaal003", "item-name");
+        deliverySourceRow.put("imaal004", "spec-001");
+        deliverySourceRow.put("xmdc006", "PCS");
+        deliverySourceRow.put("xmdc007", "100");
+        deliverySourceRow.put("xmdc012", "2026-09-30");
+        deliverySourceRow.put("xmdc015", "12.3");
+        deliverySourceRow.put("xmdc019", "child-feature");
+        deliverySourceRow.put("xmdc046", "1000");
+        deliverySourceRow.put("xmdc047", "1130");
+        deliverySourceRow.put("xmdc050", "normal-delivery");
+        deliverySourceRow.put("xmdcua003", "2026-09-20");
+
+        Map<String, Object> orderSourceRow = new LinkedHashMap<String, Object>();
+        orderSourceRow.put("xmddent", "87");
+        orderSourceRow.put("xmdddocno", "SO-001");
+        orderSourceRow.put("xmddseq", "10");
+        orderSourceRow.put("xmddseq1", "11");
+        orderSourceRow.put("xmddseq2", "12");
+        orderSourceRow.put("xmdd001", "ITEM-002");
+        orderSourceRow.put("xmdd002", "FEATURE-002");
+        orderSourceRow.put("xmdd004", "BOX");
+        orderSourceRow.put("xmdd005", "200");
+        orderSourceRow.put("xmdd006", "80");
+        orderSourceRow.put("xmdd009", "delivery-type-2");
+        orderSourceRow.put("xmdd011", "2026-10-01");
+        orderSourceRow.put("xmdd014", "20");
 
         Map<String, Object> detailData = new LinkedHashMap<String, Object>();
-        detailData.put("detailData", Collections.singletonList(detailRow));
+        detailData.put("detailData1", Collections.singletonList(deliverySourceRow));
+        detailData.put("detailData2", Collections.singletonList(orderSourceRow));
 
         ErpYidaPushRequest request = new ErpYidaPushRequest();
         request.setType(ApproveOrderFormPushHandler.TYPE);
@@ -136,15 +153,13 @@ class ApproveOrderFormPushHandlerTest {
         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"));
+        assertEquals("11", orderRow.getString("numberField_mt82p0ah"));
+        assertEquals("12", orderRow.getString("numberField_mt82p0ai"));
+        assertEquals("ITEM-002", orderRow.getString("textField_8yabff0mk"));
+        assertEquals("FEATURE-002", orderRow.getString("textField_8yabis8xh"));
+        assertEquals("BOX", orderRow.getString("textField_8yabjecf7"));
+        assertEquals("delivery-type-2", orderRow.getString("textField_mt82p0aj"));
+        assertEquals("200", orderRow.getString("textField_8yabl1tlp"));
+        assertEquals(Long.valueOf(1790784000000L), orderRow.getLong("dateField_8yabm8rmr"));
     }
 }

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

@@ -0,0 +1,115 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.ErpYidaPushMainData;
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.dingtalk.DDConf;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CustomerReleaseFormPushHandlerTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushMapsCustomerReleasePayloadToYidaFieldIds() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-1");
+        when(yidaClient.startProcess(org.mockito.ArgumentMatchers.anyMap())).thenReturn(response);
+
+        DDConf ddConf = new DDConf();
+        ddConf.setAppKey("app-key");
+        ddConf.setAppSecret("app-secret");
+
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
+        applications.getCoordinationOffice().setSystemToken("system-token");
+
+        ErpYidaPushMainData mainData = new ErpYidaPushMainData();
+        mainData.setExtraField("xmfxent", "87");
+        mainData.setExtraField("xmfxdocno", "CR-001");
+        mainData.setExtraField("xmfxdocdt", "2026-09-15");
+        mainData.setExtraField("xmfx001", "applicant-001");
+        mainData.setExtraField("xmfx002", "dept-code-001");
+        mainData.setExtraField("xmfx003", "release-desc");
+        mainData.setExtraField("xmfxud001", "cannot-deliver-reason");
+
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("xmfyent", "87");
+        detailRow.put("xmfydocno", "CR-001");
+        detailRow.put("xmfyseq", "10");
+        detailRow.put("xmfy001", "DN");
+        detailRow.put("xmfy002", "DN-001");
+        detailRow.put("xmfy003", "2026-09-16");
+        detailRow.put("xmfy004", "CUS-001");
+        detailRow.put("pmaal004", "customer-name");
+        detailRow.put("xmfy005", "1000.5");
+        detailRow.put("xmfy006", "2000.5");
+        detailRow.put("xmfy007", "short-remark");
+        detailRow.put("xmfy008", "3000.5");
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(detailRow));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(CustomerReleaseFormPushHandler.TYPE);
+        request.setBm("dept-001");
+        request.setGs("ML");
+        request.setTjr("user-001");
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+
+        CustomerReleaseFormPushHandler handler = new CustomerReleaseFormPushHandler(yidaClient, ddConf, applications);
+
+        assertEquals(Collections.singletonList("axmt140"), handler.getTypes());
+        assertEquals("process-instance-1", handler.push(request));
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(yidaClient).startProcess(bodyCaptor.capture());
+        Map body = bodyCaptor.getValue();
+        assertEquals("APP_RJJL69QUIZVQSV7YC8TL", body.get("appType"));
+        assertEquals("system-token", body.get("systemToken"));
+        assertEquals("41603502201288023", body.get("userId"));
+        assertEquals("TPROC--6TG66381N8G8Y7L9GBEEY7QVXTUY2LBBGZ7TMU", body.get("processCode"));
+        assertEquals("FORM-3BA9FE18494F4FDD85FE6ABF7DE741BAE30C", body.get("formUuid"));
+        assertEquals("customerRelease-CR-001", body.get("title"));
+        assertEquals("customerRelease-CR-001", body.get("dataTitle"));
+
+        JSONObject formData = JSONObject.parseObject(String.valueOf(body.get("formDataJson")));
+        assertEquals("customerRelease-CR-001", formData.getString("textField_mtv1tgg1"));
+        assertEquals("customerRelease-CR-001", formData.getString("textField_mspcpz92"));
+        assertEquals("41603502201288023", formData.getJSONArray("employeeField_msh186rr").getString(0));
+        assertEquals("ML", formData.getString("selectField_mtv7uxgd"));
+        assertEquals("dept-001", formData.getJSONArray("departmentSelectField_msh186ry").getString(0));
+        assertEquals(Long.valueOf(1789401600000L), formData.getLong("dateField_msh186rs"));
+        assertEquals("CR-001", formData.getString("textField_mt7zi4fy"));
+        assertEquals("release-desc", formData.getString("textField_mt7zi4g0"));
+        assertEquals("cannot-deliver-reason", formData.getString("textField_mt7zi4fz"));
+
+        JSONArray detailRows = formData.getJSONArray("tableField_msh186sv");
+        assertEquals(1, detailRows.size());
+        JSONObject row = detailRows.getJSONObject(0);
+        assertEquals("10", row.getString("textField_mt7zi4g1"));
+        assertEquals("DN-001", row.getString("textField_mt7zi4g2"));
+        assertEquals(Long.valueOf(1789488000000L), row.getLong("dateField_mt7zi4g3"));
+        assertEquals("CUS-001", row.getString("textField_mt6ur4w1"));
+        assertEquals("customer-name", row.getString("textField_mt7zi4g4"));
+        assertEquals("1000.5", row.getString("numberField_mt7zi4g5"));
+        assertEquals("2000.5", row.getString("numberField_mt7zi4g6"));
+        assertEquals("3000.5", row.getString("numberField_mt7zi4g7"));
+        assertEquals("short-remark", row.getString("textField_mt6ur4w8"));
+    }
+}

+ 55 - 5
mjava-minglei/src/test/java/com/malk/minglei/service/impl/yida/ErpYidaPushServiceImplTest.java

@@ -15,6 +15,7 @@ import org.slf4j.Logger;
 import java.util.Collections;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -65,9 +66,9 @@ class ErpYidaPushServiceImplTest {
         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");
+        Map<String, Object> pushedDetailData = mapValue(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);
+        Map<String, Object> pushedDetailRow = firstRow(pushedDetailData, "detailData");
         assertEquals("dingtalk-user-001", pushedDetailRow.get("originatorUserId"));
         assertEquals("2026-09-14", pushedDetailRow.get("applyDate"));
         assertEquals(Collections.singletonList("123456"), pushedDetailRow.get("applyDepartment"));
@@ -104,7 +105,7 @@ class ErpYidaPushServiceImplTest {
         assertNotNull(result);
         verify(formPushHandler, times(1)).push(request);
         assertEquals("dingtalk-user-001", request.getOriginatorUserId());
-        Map<String, Object> pushedDetailData = (Map<String, Object>) request.getDetailData();
+        Map<String, Object> pushedDetailData = mapValue(request.getDetailData());
         assertEquals(Collections.singleton("detailData"), pushedDetailData.keySet());
         verify(yidaProcessService, never()).startProcess(any(YidaProcessStartRequest.class));
         verify(logger).info(org.mockito.ArgumentMatchers.startsWith("ERP Yida push dispatched formInstanceId: {}"),
@@ -147,8 +148,48 @@ class ErpYidaPushServiceImplTest {
         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);
+        Map<String, Object> pushedDetailData = mapValue(request.getDetailData());
+        Map<String, Object> pushedDetailRow = firstRow(pushedDetailData, "detailData");
+        assertEquals("dingtalk-top-user-001", pushedDetailRow.get("originatorUserId"));
+        assertEquals(Collections.singletonList("top-dept-001"), pushedDetailRow.get("applyDepartment"));
+        assertEquals("top-company-001", pushedDetailRow.get("gs"));
+        verify(formPushHandler, times(1)).push(request);
+        verify(yidaProcessService, never()).startProcess(any(YidaProcessStartRequest.class));
+    }
+
+    @Test
+    void acceptsDetailDataOneForMatchingFormPushHandler() {
+        Logger logger = mock(Logger.class);
+        YidaProcessService yidaProcessService = mock(YidaProcessService.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        YidaFormPushHandler formPushHandler = mock(YidaFormPushHandler.class);
+        when(formPushHandler.getTypes()).thenReturn(Collections.singletonList(CustomerReleaseFormPushHandler.TYPE));
+        when(employeeRosterService.findByEmployeeNo("top-user-001")).thenReturn(employee("dingtalk-top-user-001"));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(CustomerReleaseFormPushHandler.TYPE);
+        request.setBm("top-dept-001");
+        request.setGs("top-company-001");
+        request.setTjr("top-user-001");
+        request.setMainData(new ErpYidaPushMainData());
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("xmfydocno", "CR-001");
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(detailRow));
+        request.setDetailData(detailData);
+        when(formPushHandler.push(request)).thenReturn("form-instance-1");
+
+        ErpYidaPushServiceImpl service = new ErpYidaPushServiceImpl(
+                logger,
+                yidaProcessService,
+                Arrays.asList(formPushHandler),
+                employeeRosterService);
+
+        McR result = service.push(request);
+
+        assertNotNull(result);
+        Map<String, Object> pushedDetailData = mapValue(request.getDetailData());
+        Map<String, Object> pushedDetailRow = firstRow(pushedDetailData, "detailData1");
         assertEquals("dingtalk-top-user-001", pushedDetailRow.get("originatorUserId"));
         assertEquals(Collections.singletonList("top-dept-001"), pushedDetailRow.get("applyDepartment"));
         assertEquals("top-company-001", pushedDetailRow.get("gs"));
@@ -192,4 +233,13 @@ class ErpYidaPushServiceImplTest {
         request.setDetailData(detailData);
         return request;
     }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> mapValue(Object value) {
+        return (Map<String, Object>) value;
+    }
+
+    private Map<String, Object> firstRow(Map<String, Object> detailData, String key) {
+        return mapValue(((List<?>) detailData.get(key)).get(0));
+    }
 }

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

@@ -69,4 +69,31 @@ class MingleiDocumentServiceImplTest {
         assertEquals(Arrays.asList("user-1", "user-2"), userIds);
         verify(contactsClient, never()).getDepartmentId_all("access-token", false, 10L);
     }
+
+    @Test
+    void listDepartmentUserIdsCombinesMultipleDepartmentsUsingTheRequestedType() {
+        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));
+        given(contactsClient.getDepartmentId_all("access-token", false, 20L)).willReturn(Arrays.asList(21L));
+        given(contactsClient.listDepartmentUserId("access-token", 10L)).willReturn(Arrays.asList("user-1", "user-shared"));
+        given(contactsClient.listDepartmentUserId("access-token", 11L)).willReturn(Arrays.asList("user-2"));
+        given(contactsClient.listDepartmentUserId("access-token", 20L)).willReturn(Arrays.asList("user-shared", "user-3"));
+        given(contactsClient.listDepartmentUserId("access-token", 21L)).willReturn(Arrays.asList("user-4"));
+
+        MingleiDocumentServiceImpl service = new MingleiDocumentServiceImpl(
+                mock(YDClient.class),
+                dingTalkClient,
+                contactsClient,
+                mock(DDConf.class),
+                new YidaApplicationsProperties(),
+                new ObjectMapper());
+
+        List<String> userIds = service.listDepartmentUserIds(Arrays.asList(10L, 20L), 0);
+
+        assertEquals(Arrays.asList("user-1", "user-shared", "user-2", "user-3", "user-4"), userIds);
+        verify(contactsClient).getDepartmentId_all("access-token", false, 10L);
+        verify(contactsClient).getDepartmentId_all("access-token", false, 20L);
+    }
 }

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

@@ -0,0 +1,136 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.ErpYidaPushMainData;
+import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.dingtalk.DDConf;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MiscReceiptIssueFormPushHandlerTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushMapsCustomerMiscReceiptIssuePayloadToYidaFieldIds() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-1");
+        when(yidaClient.startProcess(org.mockito.ArgumentMatchers.anyMap())).thenReturn(response);
+
+        DDConf ddConf = new DDConf();
+        ddConf.setAppKey("app-key");
+        ddConf.setAppSecret("app-secret");
+
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
+        applications.getCoordinationOffice().setSystemToken("system-token");
+
+        ErpYidaPushMainData mainData = new ErpYidaPushMainData();
+        mainData.setExtraField("inbaent", "87");
+        mainData.setExtraField("inbadocno", "MS-001");
+        mainData.setExtraField("inbadocdt", "2026-09-15");
+        mainData.setExtraField("inba003", "applicant-001");
+        mainData.setExtraField("inba004", "dept-code-001");
+        mainData.setExtraField("inba007", "RSN-001");
+        mainData.setExtraField("oocql004", "reason-desc-main");
+        mainData.setExtraField("inba008", "remark-001");
+        mainData.setExtraField("inba006", "ERP");
+
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("inbbent", "87");
+        detailRow.put("inbbdocno", "MS-001");
+        detailRow.put("inbbseq", "10");
+        detailRow.put("inbbua001", "SO-001");
+        detailRow.put("inbbua002", "20");
+        detailRow.put("inbb001", "ITEM-001");
+        detailRow.put("imaal003", "item-name");
+        detailRow.put("imaal004", "spec-001");
+        detailRow.put("inbb002", "feature-001");
+        detailRow.put("inbb007", "WH-001");
+        detailRow.put("inbb008", "LOC-001");
+        detailRow.put("inbb009", "LOT-001");
+        detailRow.put("inbb003", "stock-feature-001");
+        detailRow.put("inbb011", "12.5");
+        detailRow.put("inbb010", "PCS");
+        detailRow.put("inbb016", "DRSN-001");
+        detailRow.put("oocql004", "reason-desc-detail");
+        detailRow.put("inbb023", "PROJ-001");
+        detailRow.put("pjbal003", "project-desc");
+        detailRow.put("inbb017", "SRC-001");
+
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(detailRow));
+
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(MiscReceiptIssueFormPushHandler.TYPE);
+        request.setBm("dept-001");
+        request.setGs("ML");
+        request.setTjr("user-001");
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+
+        MiscReceiptIssueFormPushHandler handler = new MiscReceiptIssueFormPushHandler(yidaClient, ddConf, applications);
+
+        assertEquals("process-instance-1", handler.push(request));
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(yidaClient).startProcess(bodyCaptor.capture());
+        Map body = bodyCaptor.getValue();
+        assertEquals("APP_RJJL69QUIZVQSV7YC8TL", body.get("appType"));
+        assertEquals("system-token", body.get("systemToken"));
+        assertEquals("41603502201288023", body.get("userId"));
+        assertEquals("TPROC--MQD66371C1N84FPSJZO4W79IN4BW2AE7I08TM0", body.get("processCode"));
+        assertEquals("FORM-4B08ADABCDAA45788C043729A855E03ED76G", body.get("formUuid"));
+        assertEquals("miscReceiptIssue-MS-001", body.get("title"));
+        assertEquals("miscReceiptIssue-MS-001", body.get("dataTitle"));
+
+        JSONObject formData = JSONObject.parseObject(String.valueOf(body.get("formDataJson")));
+        assertEquals("miscReceiptIssue-MS-001", formData.getString("textField_msh27unu"));
+        assertEquals("miscReceiptIssue-MS-001", formData.getString("textField_mspcpz92"));
+        assertEquals("41603502201288023", formData.getJSONArray("employeeField_msh186rr").getString(0));
+        assertEquals("ML", formData.getString("selectField_mso1em48"));
+        assertEquals("dept-001", formData.getJSONArray("departmentSelectField_msh186ry").getString(0));
+        assertEquals(Long.valueOf(1789401600000L), formData.getLong("dateField_msh186rs"));
+        assertEquals("ERP", formData.getString("textField_mt7zi4fy"));
+        assertEquals("applicant-001", formData.getString("textField_mt7zi4g0"));
+        assertEquals("dept-code-001", formData.getString("textField_mt80nq6s"));
+        assertEquals("RSN-001", formData.getString("textField_mt80nq6t"));
+        assertEquals("reason-desc-main", formData.getString("textField_mt80nq6v"));
+        assertEquals("remark-001", formData.getString("textField_mt7zi4fz"));
+
+        JSONArray detailRows = formData.getJSONArray("tableField_msh186sv");
+        assertEquals(1, detailRows.size());
+        JSONObject row = detailRows.getJSONObject(0);
+        assertEquals("10", row.getString("textField_mt7zi4g1"));
+        assertEquals("SO-001", row.getString("textField_mt80nq6y"));
+        assertEquals("20", row.getString("textField_mt80nq6z"));
+        assertEquals("ITEM-001", row.getString("textField_mt80nq70"));
+        assertEquals("item-name", row.getString("textField_mt80nq71"));
+        assertEquals("spec-001", row.getString("textField_mt80nq72"));
+        assertEquals("feature-001", row.getString("textField_mt80nq73"));
+        assertEquals("WH-001", row.getString("textField_mt80nq74"));
+        assertEquals("LOC-001", row.getString("textField_mt80nq75"));
+        assertEquals("LOT-001", row.getString("textField_mt7zi4g2"));
+        assertEquals("stock-feature-001", row.getString("textField_mt80nq76"));
+        assertEquals("12.5", row.getString("numberField_mt80nq77"));
+        assertEquals("PCS", row.getString("textField_mt6ur4w1"));
+        assertEquals("DRSN-001", row.getString("textField_mt7zi4g4"));
+        assertEquals("reason-desc-detail", row.getString("textField_mt80nq79"));
+        assertEquals("PROJ-001", row.getString("textField_mt6ur4w8"));
+        assertEquals("project-desc", row.getString("textField_mt80nq7a"));
+        assertEquals("SRC-001", row.getString("textField_mt80nq7b"));
+    }
+}

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

@@ -26,6 +26,7 @@ import static org.mockito.Mockito.when;
 class MlWorkOrderCraftPriceFormPushHandlerTest {
 
     @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
     void pushMapsPayloadToWorkOrderCraftPriceFormFieldIds() {
         YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
         DDR_New response = new DDR_New();
@@ -89,6 +90,7 @@ class MlWorkOrderCraftPriceFormPushHandlerTest {
 
         MlWorkOrderCraftPriceFormPushHandler handler = new MlWorkOrderCraftPriceFormPushHandler(yidaClient, ddConf, applications);
 
+        assertEquals(Collections.singletonList("FORM-4403B45A7F184CB79088FCDAFD2FD925LZ3K"), handler.getTypes());
         assertEquals("process-instance-1", handler.push(request));
 
         ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);