Переглянути джерело

feat: add mjava-minglei project

ht 2 тижнів тому
батько
коміт
56d0bf3d58
38 змінених файлів з 2449 додано та 0 видалено
  1. 7 0
      mjava-minglei/.gitignore
  2. 1 0
      mjava-minglei/.mvn/maven.config
  3. 364 0
      mjava-minglei/README.md
  4. 70 0
      mjava-minglei/pom.xml
  5. 12 0
      mjava-minglei/src/main/java/com/malk/minglei/MjavaMingleiApplication.java
  6. 99 0
      mjava-minglei/src/main/java/com/malk/minglei/config/HttpRequestResponseLoggingFilter.java
  7. 28 0
      mjava-minglei/src/main/java/com/malk/minglei/config/MingleiPortConfiguration.java
  8. 29 0
      mjava-minglei/src/main/java/com/malk/minglei/config/MingleiPortProperties.java
  9. 65 0
      mjava-minglei/src/main/java/com/malk/minglei/config/MjavaYidaConfiguration.java
  10. 48 0
      mjava-minglei/src/main/java/com/malk/minglei/config/YidaApplicationsProperties.java
  11. 34 0
      mjava-minglei/src/main/java/com/malk/minglei/controller/GlobalExceptionHandler.java
  12. 75 0
      mjava-minglei/src/main/java/com/malk/minglei/controller/MingleiPortController.java
  13. 103 0
      mjava-minglei/src/main/java/com/malk/minglei/controller/YidaProcessController.java
  14. 43 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/ApiResponse.java
  15. 42 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/DepartmentUserIdsResponse.java
  16. 51 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/DepartmentUserQueryRequest.java
  17. 42 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/MingleiDocumentQueryRequest.java
  18. 86 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/MingleiPortRequest.java
  19. 45 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/StartProcessRequest.java
  20. 16 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/YidaOperationParam.java
  21. 59 0
      mjava-minglei/src/main/java/com/malk/minglei/dto/YidaProcessStartRequest.java
  22. 35 0
      mjava-minglei/src/main/java/com/malk/minglei/service/MingleiDocumentService.java
  23. 19 0
      mjava-minglei/src/main/java/com/malk/minglei/service/MingleiPortService.java
  24. 25 0
      mjava-minglei/src/main/java/com/malk/minglei/service/YidaProcessHandler.java
  25. 14 0
      mjava-minglei/src/main/java/com/malk/minglei/service/YidaProcessService.java
  26. 89 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/BomYidaProcessHandler.java
  27. 357 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/MingleiDocumentServiceImpl.java
  28. 132 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/MingleiPortServiceImpl.java
  29. 54 0
      mjava-minglei/src/main/java/com/malk/minglei/service/impl/YidaProcessServiceImpl.java
  30. 39 0
      mjava-minglei/src/main/resources/application.yml
  31. 50 0
      mjava-minglei/src/main/resources/logback-spring.xml
  32. 22 0
      mjava-minglei/src/test/java/com/malk/minglei/MjavaMingleiApplicationTest.java
  33. 46 0
      mjava-minglei/src/test/java/com/malk/minglei/controller/YidaProcessControllerTest.java
  34. 28 0
      mjava-minglei/src/test/java/com/malk/minglei/dto/ApiResponseTest.java
  35. 28 0
      mjava-minglei/src/test/java/com/malk/minglei/dto/DepartmentUserIdsResponseTest.java
  36. 72 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/MingleiDocumentServiceImplTest.java
  37. 74 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/MingleiPortServiceImplTest.java
  38. 46 0
      mjava-minglei/src/test/java/com/malk/minglei/service/impl/YidaProcessServiceImplTest.java

+ 7 - 0
mjava-minglei/.gitignore

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

+ 1 - 0
mjava-minglei/.mvn/maven.config

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

+ 364 - 0
mjava-minglei/README.md

@@ -0,0 +1,364 @@
+# mjava-minglei
+
+明磊业务集成服务。工程基于 Spring Boot 构建,通过统一的 `malk:mjava` 客户端对接钉钉和宜搭,同时提供明磊内部外部接口的转发能力。
+
+## 1. 技术栈
+
+| 技术 | 版本或用途 |
+| --- | --- |
+| Java | JDK 8 |
+| Spring Boot | 2.7.18 |
+| Spring MVC | REST Controller、参数绑定和异常处理 |
+| Spring Validation | `@Valid`、`@NotBlank`、`@NotEmpty` 等请求校验 |
+| Maven | 项目构建和依赖管理 |
+| `com.malk:mjava` | 统一的钉钉、宜搭客户端和数据模型,当前版本 0.0.3 |
+| Jackson / Fastjson | JSON 请求绑定、序列化和宜搭字段数据组装 |
+| RestTemplate | 调用明磊外部 HTTP 接口 |
+| Logback + SLF4J | 控制台、应用文件和错误文件日志 |
+| JUnit 5 + Mockito | 单元测试和客户端调用测试 |
+
+## 2. 工程架构
+
+工程采用典型的 Controller - Service - Impl 分层结构:
+
+```text
+MjavaMingleiApplication
+        |
+        v
+Controller 层       接收 HTTP 请求、校验参数、返回 HTTP 响应
+        |
+        v
+Service 接口层      定义业务能力和模块边界
+        |
+        v
+ServiceImpl 层      编排宜搭、钉钉或外部 T100 调用
+        |
+        +--> malk YDClient / DDClient
+        +--> DingTalk Open Platform
+        +--> 明磊外部 T100 HTTP 接口
+```
+
+### 2.1 目录职责
+
+```text
+src/main/java/com/malk/minglei
+├── MjavaMingleiApplication.java       Spring Boot 启动类
+├── config                             Bean、配置属性、HTTP 日志过滤器
+├── controller                         REST 接口和局部异常处理
+├── dto                                请求模型和统一响应模型
+└── service
+    ├── *.java                          业务接口
+    └── impl                            业务实现和外部系统调用
+
+src/main/resources
+├── application.yml                    服务、钉钉、宜搭、外部接口配置
+└── logback-spring.xml                 日志输出和滚动策略
+
+src/test/java                           单元测试和集成上下文测试
+```
+
+### 2.2 配置和客户端装配
+
+`MjavaYidaConfiguration` 注册两套独立的宜搭客户端:
+
+- `coordinationOfficeYidaClient`:明磊锂能协调办公应用。
+- `masterDataYidaClient`:明磊锂能应用主数据。
+
+两套客户端使用各自的 `appType` 和 `systemToken`,业务实现通过 `@Qualifier` 选择,避免多个 `YDClient` Bean 注入时产生歧义。钉钉客户端由 malk 包提供,宜搭客户端内部通过 `YDClientImpl` 调用表单和流程接口。
+
+## 3. 业务模块
+
+### 3.1 宜搭统一流程发起
+
+相关类:`YidaProcessController`、`YidaProcessService`、`YidaProcessServiceImpl`、`YidaProcessHandler`、`BomYidaProcessHandler`。
+
+统一入口根据请求体的 `type` 分发处理器。`type` 的业务含义是目标宜搭表单的 `formUuid`,不是自定义的业务名称。例如 BOM 表单使用:
+
+```text
+FORM-04001902BF9C40E6859102569FE139179LWH
+```
+
+分发前会去除首尾空格并转为大写匹配。新增表单时,应新增一个 `YidaProcessHandler` 实现类,并让 `getType()` 返回该表单的 `formUuid`;表单 ID、流程编码和应用凭证由实现类或配置管理,不由调用方传入。
+
+处理流程如下:
+
+1. Controller 使用 `@Valid` 校验请求体。
+2. `YidaProcessServiceImpl` 根据规范化后的表单 ID 查找处理器。
+3. 具体处理器组装 `YidaOperationParam`。
+4. 通过对应应用的 `YDClient.operateData(..., FORM_OPERATION.start)` 发起宜搭流程。
+5. Controller 返回宜搭流程实例 ID。
+
+### 3.2 明磊主数据表单查询
+
+相关类:`MingleiDocumentService`、`MingleiDocumentServiceImpl`、`YidaProcessController`。
+
+主数据查询固定使用主数据应用客户端和目标表单 ID。支持两种查询方式:
+
+- 分页查询:返回宜搭查询结果中的 `records`、`totalCount`、`pageNumber`。
+- 精准查询:条件优先级为 `dept_id` > `user_id` > `dept_name`。传入 `user_id` 时,先调用钉钉通讯录接口取得员工所属部门,再按部门 ID 查询。查询成功后仅提取一级至四级部门主管和部门分管领导字段对应的钉钉 `userId`。
+
+### 3.3 部门成员查询
+
+`MingleiDocumentServiceImpl.listDepartmentUserIds` 先取得钉钉访问令牌,再按 `type` 决定查询范围:`0` 获取指定部门及所有子部门,`1` 仅获取指定部门;随后逐个读取部门成员并按首次出现顺序去重,返回用户 ID 列表。
+
+### 3.4 明磊外部 T100 接口转发
+
+相关类:`MingleiPortController`、`MingleiPortService`、`MingleiPortServiceImpl`。
+
+该模块是本服务提供的本地触发入口,收到精简的 `head` 和 `detail` 后,由服务端补齐固定的外层协议结构,再通过 `RestTemplate` 调用明磊外部接口。连接超时为 10 秒,读取超时为 30 秒,目标 URL 和接入密钥通过配置注入。
+
+## 4. REST 接口
+
+### 4.1 宜搭流程发起
+
+```http
+POST /api/yida/process-instances
+Content-Type: application/json
+```
+
+请求示例:
+
+```json
+{
+  "type": "FORM-04001902BF9C40E6859102569FE139179LWH",
+  "userId": "dingTalkUserId",
+  "dataTitle": "流程标题",
+  "fields": {
+    "textField_xxx": "字段值"
+  }
+}
+```
+
+`userId` 也可使用请求别名 `originatorUserId`。`fields` 的键必须是目标宜搭表单真实存在的字段 ID。成功返回 HTTP `201 Created`:
+
+```json
+{
+  "code": 200,
+  "isSuccess": true,
+  "result": {
+    "processInstanceId": "宜搭流程实例 ID"
+  }
+}
+```
+
+该接口使用统一的 `ApiResponse` 包装格式,流程实例 ID 位于 `result.processInstanceId`。
+
+### 4.2 分页查询主数据单据
+
+```http
+GET /api/yida/minglei/forms/instances?page=1&size=20
+```
+
+`page` 从 1 开始,`size` 范围为 1 至 100。成功响应为宜搭查询结果的服务端整理结构:
+
+```json
+{
+  "records": [],
+  "totalCount": 0,
+  "pageNumber": 1
+}
+```
+
+### 4.3 精准查询主数据单据
+
+```http
+POST /api/yida/minglei/forms/instances/query
+Content-Type: application/json
+```
+
+请求示例:
+
+```json
+{
+  "dept_id": "123456",
+  "user_id": "dingTalkUserId",
+  "dept_name": "明磊锂能技术部"
+}
+```
+
+返回示例:
+
+```json
+{
+  "employeeField_mszdqrl8": "一级部门主管 userId",
+  "employeeField_mszdqrl9": "二级部门主管 userId",
+  "employeeField_mszdqrla": "三级部门主管 userId",
+  "employeeField_mszdqrlb": "四级部门主管 userId",
+  "employeeField_mszdqrl3": "部门分管领导 userId"
+}
+```
+
+### 4.4 查询部门及子部门全部成员
+
+```http
+POST /api/yida/minglei/departments/users
+Content-Type: application/json
+```
+
+请求体:
+
+```json
+{
+  "dept_Id": "123456",
+  "type": 0
+}
+```
+
+`type` 为必填字段:`0` 表示穿透查询子部门,`1` 表示仅查询指定部门。
+
+成功响应统一为 `code`、`isSuccess`、`result` 三层结构:
+
+```json
+{
+  "code": 200,
+  "isSuccess": true,
+  "result": [
+    "449856319",
+    "715357119"
+  ]
+}
+```
+
+### 4.5 转发供应商价格审批接口
+
+```http
+POST /api/minglei/ports/supplier-price-approvals
+Content-Type: application/json
+```
+
+请求体只需要提供协议中的 `head` 和 `detail`:
+
+```json
+{
+  "head": {
+    "pmdidocno": "CH3",
+    "pmdi001": "N",
+    "pmdi034": "605188",
+    "pmdidocdt": "2026-08-06",
+    "pmdi004": "517022",
+    "pmdi015": "2026-08-06",
+    "pmdi016": "2027-06-01",
+    "pmdi003": "A043",
+    "pmdi002": "210721017",
+    "pmdiud001": "N",
+    "pmdistus": "I"
+  },
+  "detail": [
+    {
+      "pmdj011": "0.5000",
+      "pmdj002": "524000059517022000000",
+      "pmdj030": ""
+    }
+  ]
+}
+```
+
+服务端会自动补齐 `type`、`host`、`service`、`datakey`、`payload` 等固定协议字段,并返回外部接口的原始 JSON 响应。
+
+## 5. 统一响应和异常处理
+
+新增接口优先使用 `ApiResponse<T>`,成功响应约定为:
+
+```json
+{
+  "code": 200,
+  "isSuccess": true,
+  "result": {}
+}
+```
+
+部门成员接口使用专用的 `DepartmentUserIdsResponse`。历史单据查询接口为兼容已有调用方,保留当前响应结构;流程发起接口使用 `ApiResponse`。
+
+异常处理分为两层:
+
+- Controller 局部处理请求体解析和参数校验失败,返回 HTTP 400。
+- `GlobalExceptionHandler` 处理配置缺失,返回 HTTP 412;未分类的外部调用异常记录错误日志并返回 HTTP 502。
+
+## 6. 配置说明
+
+配置文件为 `src/main/resources/application.yml`。生产环境建议通过环境变量注入,不要把真实密钥提交到代码仓库。
+
+| 配置项 | 环境变量 | 用途 |
+| --- | --- | --- |
+| `server.port` | `SERVER_PORT` | HTTP 服务端口,默认 80 |
+| `spring.profiles.active` | `SPRING_PROFILES_ACTIVE` | Spring Profile,默认 `dev` |
+| `dingtalk.app-key` | `DINGTALK_APP_KEY` | 钉钉应用 Key |
+| `dingtalk.app-secret` | `DINGTALK_APP_SECRET` | 钉钉应用 Secret |
+| `minglei.yida.coordination-office.app-type` | `YIDA_COORDINATION_OFFICE_APP_TYPE` | 协调办公应用编码 |
+| `minglei.yida.coordination-office.system-token` | `YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN` | 协调办公应用 System Token |
+| `minglei.yida.master-data.app-type` | `YIDA_MASTER_DATA_APP_TYPE` | 主数据应用编码 |
+| `minglei.yida.master-data.system-token` | `YIDA_MASTER_DATA_SYSTEM_TOKEN` | 主数据应用 System Token |
+| `minglei.port.supplier-price-approval-url` | `MINGLEI_SUPPLIER_PRICE_APPROVAL_URL` | 外部 T100 接口地址 |
+| `minglei.port.supplier-price-approval-key` | `MINGLEI_SUPPLIER_PRICE_APPROVAL_KEY` | 外部接口接入密钥 |
+| `logging.file.path` | `LOGGING_FILE_PATH` | 日志目录,默认 `logs` |
+
+### 日志说明
+
+Logback 同时输出:
+
+- 控制台日志。
+- `${LOGGING_FILE_PATH}/application.log` 应用日志。
+- `${LOGGING_FILE_PATH}/error.log` 错误日志。
+- `archive` 子目录中的按日期、大小滚动归档日志。
+
+`HttpRequestResponseLoggingFilter` 会记录请求方法、URI、状态码、耗时、请求体和响应体,单个载荷最多记录 10KB,并对 `password`、`secret`、`token`、`authorization`、`accessToken`、`systemToken`、`key` 等字段脱敏。日志目录必须对运行账号可写,否则可能导致 Logback 初始化失败。
+
+## 7. 启动和构建
+
+环境要求:JDK 8、Maven 3.6+,并确保能够访问项目依赖仓库及内部 malk 依赖。
+
+```powershell
+# 根据实际环境填写,不要把真实凭证提交到 README 或代码仓库
+$env:SERVER_PORT = "8080"
+$env:DINGTALK_APP_KEY = "<dingTalk-app-key>"
+$env:DINGTALK_APP_SECRET = "<dingTalk-app-secret>"
+$env:YIDA_COORDINATION_OFFICE_APP_TYPE = "<coordination-office-app-type>"
+$env:YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN = "<coordination-office-system-token>"
+$env:YIDA_MASTER_DATA_APP_TYPE = "<master-data-app-type>"
+$env:YIDA_MASTER_DATA_SYSTEM_TOKEN = "<master-data-system-token>"
+
+mvn clean package
+mvn spring-boot:run
+```
+
+打包后也可以运行:
+
+```powershell
+java -jar target\mjava-minglei-1.0.0-SNAPSHOT.jar
+```
+
+## 8. 测试
+
+测试代码位于 `src/test/java`,覆盖:
+
+- Spring Boot 上下文加载。
+- `ApiResponse` 和部门用户响应的 JSON 结构。
+- 宜搭主数据查询条件、部门成员查询和负责人 ID 解析。
+- 宜搭流程处理器按表单 ID 分发。
+- 外部 T100 请求的固定协议组装和响应转发。
+
+执行测试:
+
+```powershell
+mvn test
+```
+
+## 9. 扩展宜搭表单流程
+
+新增可发起的宜搭流程时,按以下步骤实现:
+
+1. 新建 `XxxYidaProcessHandler`,实现 `YidaProcessHandler`。
+2. 定义该表单真实的 `FORM_UUID`,让 `getType()` 返回该表单 ID。
+3. 在 `startProcess` 中选择正确的 `YDClient`,组装表单 ID、流程编码和表单字段。
+4. 在 `YidaProcessServiceImplTest` 增加表单 ID 分发测试。
+5. 同步更新接口文档中的支持类型和请求示例。
+
+调用方只传 `type`、发起人、标题和 `fields`,不传应用凭证、表单 ID 或流程编码之外的服务端配置。不同宜搭应用必须使用对应的具名客户端,避免协调办公和主数据凭证混用。
+
+## 10. 安全和运维注意事项
+
+- 不要在 README、提交记录或日志中写入真实 App Secret、System Token、接入密钥或访问令牌。
+- 宜搭和钉钉接口所需的网络连通性、权限范围和组织数据权限由部署环境保证。
+- 当前 Controller 未内置业务鉴权;如需访问控制,应在网关或统一认证层配置。
+- 发生“日志文件拒绝访问”时,优先检查 `LOGGING_FILE_PATH` 目录是否存在以及运行账号是否具有写权限。
+- 发生宜搭配置不匹配时,确认应用编码、System Token 与处理器选择的客户端属于同一应用。

+ 70 - 0
mjava-minglei/pom.xml

@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.7.18</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.malk</groupId>
+    <artifactId>mjava-minglei</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+    <name>mjava-minglei</name>
+    <description>Yida process integration based on mjava</description>
+
+    <properties>
+        <java.version>1.8</java.version>
+        <maven.compiler.source>1.8</maven.compiler.source>
+        <maven.compiler.target>1.8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>0.0.3</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.springframework.boot</groupId>
+                    <artifactId>spring-boot-starter-data-jpa</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.springframework.boot</groupId>
+                    <artifactId>spring-boot-starter-jdbc</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>mysql</groupId>
+                    <artifactId>mysql-connector-java</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,34 @@
+package com.malk.minglei.controller;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+    @ExceptionHandler(IllegalStateException.class)
+    public ResponseEntity<Map<String, String>> handleConfiguration(IllegalStateException exception) {
+        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED)
+                .body(Collections.singletonMap("message", exception.getMessage()));
+    }
+
+    @ExceptionHandler(Exception.class)
+    public ResponseEntity<Map<String, String>> handleUnexpected(Exception exception) {
+        LOGGER.error("Yida client request failed", exception);
+
+        Map<String, String> response = new LinkedHashMap<String, String>();
+        response.put("code", "YIDA_CALL_FAILED");
+        response.put("message", "Yida client request failed. Check the application log for details.");
+        return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(response);
+    }
+}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 39 - 0
mjava-minglei/src/main/resources/application.yml

@@ -0,0 +1,39 @@
+server:
+  port: ${SERVER_PORT:80}
+  tomcat:
+    accesslog:
+      enabled: true
+      directory: ${logging.file.path}
+      prefix: access
+      suffix: .log
+      file-date-format: .yyyy-MM-dd
+      pattern: '%t %a "%r" %s %b %D'
+
+spring:
+  application:
+    name: mjava-minglei
+  profiles:
+    active: ${SPRING_PROFILES_ACTIVE:dev}
+
+dingtalk:
+  app-key: ${DINGTALK_APP_KEY:dingpdriyshfwyzxhe9y}
+  app-secret: ${DINGTALK_APP_SECRET:z1YxVWLauwNdns9cyIJEg58Lz0S2XLdxMmbOkk-xISmeHoKdmusyt2xB4N8vpIFj}
+
+minglei:
+  port:
+    supplier-price-approval-url: ${MINGLEI_SUPPLIER_PRICE_APPROVAL_URL:http://10.0.2.60/wtoptst/ws/r/awsp920}
+    supplier-price-approval-key: ${MINGLEI_SUPPLIER_PRICE_APPROVAL_KEY:f5458f5c0f9022db743a7c0710145903}
+  yida:
+    coordination-office:
+      app-type: ${YIDA_COORDINATION_OFFICE_APP_TYPE:APP_RJJL69QUIZVQSV7YC8TL}
+      system-token: ${YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN:2MA66M81HY28S8O9JEQFY4MRACKO39M3UVGSMIV3}
+    master-data:
+      app-type: ${YIDA_MASTER_DATA_APP_TYPE:APP_KI0AOTH7OLFS2UAUHZXI}
+      system-token: ${YIDA_MASTER_DATA_SYSTEM_TOKEN:0E966191H5C8CRUCHOOOL4UMHY7Y2A2K8QTSMDY7}
+
+logging:
+  file:
+    path: ${LOGGING_FILE_PATH:logs}
+  level:
+    com.malk.minglei: INFO
+    com.malk.service.aliwork: INFO

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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