Forráskód Böngészése

接口开发10.27

ht 1 hete%!(EXTRA string=óta)
szülő
commit
830ebd898b
38 módosított fájl, 2744 hozzáadás és 142 törlés
  1. 42 5
      DATA-DICTIONARY-API.md
  2. 281 9
      README.md
  3. 11 0
      src/main/java/com/malk/minglei/config/BankingProperties.java
  4. 15 0
      src/main/java/com/malk/minglei/config/EmployeeRosterConfiguration.java
  5. 8 2
      src/main/java/com/malk/minglei/config/HttpRequestResponseLoggingFilter.java
  6. 10 0
      src/main/java/com/malk/minglei/config/YidaApplicationsProperties.java
  7. 131 2
      src/main/java/com/malk/minglei/controller/BankingController.java
  8. 16 0
      src/main/java/com/malk/minglei/controller/EmployeeRosterController.java
  9. 37 2
      src/main/java/com/malk/minglei/controller/GlobalExceptionHandler.java
  10. 16 2
      src/main/java/com/malk/minglei/controller/MingleiPortController.java
  11. 21 3
      src/main/java/com/malk/minglei/controller/YidaProcessController.java
  12. 50 0
      src/main/java/com/malk/minglei/dto/EmployeeRosterInfo.java
  13. 49 3
      src/main/java/com/malk/minglei/dto/SupplierPriceApprovalProcessRequest.java
  14. 13 0
      src/main/java/com/malk/minglei/service/approval/DingTalkApprovalClient.java
  15. 4 0
      src/main/java/com/malk/minglei/service/banking/BankingService.java
  16. 18 0
      src/main/java/com/malk/minglei/service/employee/EmployeeRosterService.java
  17. 35 0
      src/main/java/com/malk/minglei/service/impl/approval/DingTalkApprovalClientImpl.java
  18. 150 7
      src/main/java/com/malk/minglei/service/impl/banking/BankingServiceImpl.java
  19. 129 8
      src/main/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImpl.java
  20. 45 0
      src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRecord.java
  21. 82 3
      src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepository.java
  22. 326 0
      src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImpl.java
  23. 91 20
      src/main/java/com/malk/minglei/service/impl/port/MingleiPortServiceImpl.java
  24. 24 2
      src/main/java/com/malk/minglei/service/impl/port/ReplyErpServiceImpl.java
  25. 552 56
      src/main/java/com/malk/minglei/service/impl/port/SupplierPriceApprovalProcessServiceImpl.java
  26. 179 3
      src/main/java/com/malk/minglei/service/impl/yida/DingTalkYidaProcessOpenApiClient.java
  27. 9 0
      src/main/java/com/malk/minglei/service/port/MingleiPortService.java
  28. 15 4
      src/main/java/com/malk/minglei/service/port/SupplierPriceApprovalProcessService.java
  29. 8 0
      src/main/java/com/malk/minglei/service/yida/YidaProcessOpenApiClient.java
  30. 5 0
      src/main/resources/application.yml
  31. 5 0
      src/main/resources/db/employee_roster.sql
  32. 3 1
      src/test/java/com/malk/minglei/config/WebCorsConfigurationTest.java
  33. 40 0
      src/test/java/com/malk/minglei/controller/EmployeeRosterControllerTest.java
  34. 23 3
      src/test/java/com/malk/minglei/controller/YidaProcessControllerTest.java
  35. 22 0
      src/test/java/com/malk/minglei/service/impl/approval/DingTalkApprovalClientImplTest.java
  36. 252 4
      src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java
  37. 26 2
      src/test/java/com/malk/minglei/service/impl/port/MingleiPortServiceImplTest.java
  38. 1 1
      src/test/java/com/malk/minglei/service/impl/port/ReplyErpServiceImplTest.java

+ 42 - 5
DATA-DICTIONARY-API.md

@@ -40,14 +40,17 @@
 | `erp-supplier` | ERP 供应商视图 | `t100id`、`ent` | `pmaal004` |
 | `erp-packaging-b9-window-view` | 明磊包材 B9 开窗视图 | `t100id`、`ent` | `keyword` |
 | `erp-packaging-material` | 包材材质 | `ent` | `keyword` |
+| `erp-material-original-price` | 根据材料编号取原单价 | `czbh`、`ent` | 无 |
 | `erp-item-view` | ERP 料件视图 | `ent` | `keyword` |
-| `erp-sales-order` | 销售订单开窗 | `t100id` | 无 |
+| `erp-sales-order` | 销售订单开窗 | `t100id` | `keyword` |
 | `erp-production-order` | 生产令开窗 | `t100id`、`qx` | 无 |
 | `erp-supplier-disabled` | ERP 供应商禁用视图 | `t100id`、`ent` | `keyword` |
 | `erp-active-item` | T100 有效料号 | 无 | `keyword` |
 | `erp-template-item` | 范本料号开窗 | 无 | `keyword` |
 | `erp-template-orders` | 范本料号开窗对应所有订单 | `bgfblh` | `keyword` |
 | `erp-last-price` | 取料号上次价格 | `main.pmdi004`、`detail_1.ljbh` | 无 |
+| `erp-unfulfilled-order` | 已审核未发齐出通单 | `ent`、`site`、`keyword` | 无 |
+| `erp-delivery-order` | 出通单列表 | `ent`、`site` | `keyword` |
 | `erp-outsourced-purchase-order-window` | 委外采购单开窗 | `formtable_main_158_dt1_wwcgdh` | 无 |
 | `erp-outsourced-bom` | 委外采购对应工单备料 | `formtable_main_158_dt1_wwcgdh`、`formtable_main_158_dt1_cgxc` | 无 |
 
@@ -84,6 +87,16 @@ GET /api/dictionary/erp-packaging-material?ent=明磊&keyword=彩盒
 - `tp` 将 ERP 编码转换为 `说明书`、`彩盒`、`纸箱`、`标贴`。
 - 返回字段:`pmaauc001`、`pmaauc002`、`tp`。
 
+### 4.3.1 根据材料编号取原单价
+
+```http
+GET /api/dictionary/erp-material-original-price?czbh=B9-001&ent=明磊
+```
+
+- `czbh`:材料编号,必填,按 `pmaauc001` 精确匹配。
+- `ent`:公司名称,必填,仅支持 `明磊` 或 `越南`。
+- 返回字段:`pmaauc002`、`pmaauc015`、`pmaauc001`、`pmaaucent`。
+
 ### 4.4 ERP 料件视图
 
 ```http
@@ -96,10 +109,11 @@ GET /api/dictionary/erp-item-view?ent=明磊&keyword=电池
 ### 4.5 销售订单开窗
 
 ```http
-GET /api/dictionary/erp-sales-order?t100id=ML
+GET /api/dictionary/erp-sales-order?t100id=ML&keyword=SO-001
 ```
 
 - 固定查询公司 `86` 的已审核订单。
+- `keyword`:可选,按销售订单号 `xmdadocno` 模糊匹配。
 - 仅返回存在未交数量、料号不以 `6` 开头且不属于指定排除客户的订单。
 - 返回订单、客户、料件、未交数量和开票关联信息。
 
@@ -165,7 +179,30 @@ GET /api/dictionary/erp-last-price?main.pmdi004=SUP-001&detail_1.ljbh=B9-001
 - 固定查询公司 `86`、据点 `ML` 中有效且在生效期间内的采购价格单。
 - 按相同料号和料件编号取最新确认时间,返回价格字段 `pmdj011`。
 
-### 4.12 委外采购单开窗
+### 4.12 已审核未发齐出通单
+
+```http
+GET /api/dictionary/erp-unfulfilled-order?ent=明磊&site=ML&keyword=MLCT6261023002-1
+```
+
+- `ent`:公司名称,必填,仅支持 `明磊` 或 `越南`。
+- `site`:据点,必填。
+- `keyword`:出通单号与项次,必填,按 `出通单号-项次` 精确匹配,例如 `MLCT6261023002-1`。
+- 返回出通单号 `xmdgdocno`、项次 `xmdhseq`、内外销 `xmda048`、生产令 `xmdcud010`、料号 `xmdh006`、料件中文名称 `imaal003`、规格 `imaal004`、客户编号 `xmdg005` 和客户中文名称 `pmaal003`。
+
+### 4.13 出通单列表
+
+```http
+GET /api/dictionary/erp-delivery-order?ent=明磊&site=ML&keyword=110001-1
+```
+
+- `ent`:公司名称,必填,仅支持 `明磊` 或 `越南`。
+- `site`:据点,必填。
+- `keyword`:可选,按拼接后的“出通单号-项次”模糊匹配,例如 `110001-1`。
+- 仅返回状态为 `Y`、`H`、`N` 的出通单明细。
+- 返回出通单号 `xmdgdocno`、生产令 `xmdcud010`、样品标识 `xmdc019` 和未发数量 `k`;`xmdc019=9` 返回“样品”,其他值返回“一般”。
+
+### 4.14 委外采购单开窗
 
 ```http
 GET /api/dictionary/erp-outsourced-purchase-order-window?formtable_main_158_dt1_wwcgdh=WWCG-001
@@ -173,9 +210,9 @@ GET /api/dictionary/erp-outsourced-purchase-order-window?formtable_main_158_dt1_
 
 - `formtable_main_158_dt1_wwcgdh`:采购单号,必填。
 - 固定查询公司 `86`、据点 `ML`。
-- 返回字段:`pmdpseq`、`pmdp003`、`pmdp001`、`imaal003`、`imaal004`、`pmdp023`。
+- 返回字段:`pmdpseq`、`pmdp003`、`pmdp001`、`imaal003`、`imaal004`、`pmdp023`、`sfaaud009`。
 
-### 4.13 委外采购对应工单备料
+### 4.15 委外采购对应工单备料
 
 ```http
 GET /api/dictionary/erp-outsourced-bom?formtable_main_158_dt1_wwcgdh=WWCG-001&formtable_main_158_dt1_cgxc=10

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 281 - 9
README.md


+ 11 - 0
src/main/java/com/malk/minglei/config/BankingProperties.java

@@ -8,7 +8,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
 @ConfigurationProperties(prefix = "minglei.banking")
 public class BankingProperties {
 
+    /** 支付、状态、流水、余额和支行查询专用测试环境地址,不用于回单下载。 */
     private String baseUrl = "https://sandbox.nexbankapi.com";
+    /** 电子回单下载专用正式环境地址,不影响其他银企接口。 */
+    private String receiptBaseUrl = "https://open.nexbankapi.com";
     private String appId;
     private String appSecret;
     private String customerCode;
@@ -18,11 +21,15 @@ public class BankingProperties {
     private boolean paymentEncryptionEnabled = true;
     private String xencioPublicKey;
     private String enterprisePrivateKey;
+    private String receiptFileStoragePath = "/minglei/pay_file";
+    private String publicBaseUrl = "http://www.minglei.online:55580";
     private int connectTimeoutMillis = 10000;
     private int readTimeoutMillis = 30000;
 
     public String getBaseUrl() { return baseUrl; }
     public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
+    public String getReceiptBaseUrl() { return receiptBaseUrl; }
+    public void setReceiptBaseUrl(String receiptBaseUrl) { this.receiptBaseUrl = receiptBaseUrl; }
     public String getAppId() { return appId; }
     public void setAppId(String appId) { this.appId = appId; }
     public String getAppSecret() { return appSecret; }
@@ -41,6 +48,10 @@ public class BankingProperties {
     public void setXencioPublicKey(String xencioPublicKey) { this.xencioPublicKey = xencioPublicKey; }
     public String getEnterprisePrivateKey() { return enterprisePrivateKey; }
     public void setEnterprisePrivateKey(String enterprisePrivateKey) { this.enterprisePrivateKey = enterprisePrivateKey; }
+    public String getReceiptFileStoragePath() { return receiptFileStoragePath; }
+    public void setReceiptFileStoragePath(String receiptFileStoragePath) { this.receiptFileStoragePath = receiptFileStoragePath; }
+    public String getPublicBaseUrl() { return publicBaseUrl; }
+    public void setPublicBaseUrl(String publicBaseUrl) { this.publicBaseUrl = publicBaseUrl; }
     public int getConnectTimeoutMillis() { return connectTimeoutMillis; }
     public void setConnectTimeoutMillis(int connectTimeoutMillis) { this.connectTimeoutMillis = connectTimeoutMillis; }
     public int getReadTimeoutMillis() { return readTimeoutMillis; }

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

@@ -7,7 +7,9 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
 import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.springframework.transaction.PlatformTransactionManager;
 
 import javax.sql.DataSource;
 
@@ -54,6 +56,19 @@ public class EmployeeRosterConfiguration {
         return new JdbcTemplate(dataSource);
     }
 
+    /**
+     * 为工号日序列预留和花名册写入提供同一数据源事务,避免并发创建时生成重复工号。
+     *
+     * @param dataSource 员工花名册数据源
+     * @return 员工花名册事务管理器
+     */
+    @Bean("employeeRosterTransactionManager")
+    @ConditionalOnBean(name = "employeeRosterDataSource")
+    public PlatformTransactionManager employeeRosterTransactionManager(
+            @Qualifier("employeeRosterDataSource") DataSource dataSource) {
+        return new DataSourceTransactionManager(dataSource);
+    }
+
     private boolean hasText(String value) {
         return value != null && !value.trim().isEmpty();
     }

+ 8 - 2
src/main/java/com/malk/minglei/config/HttpRequestResponseLoggingFilter.java

@@ -24,12 +24,13 @@ public class HttpRequestResponseLoggingFilter extends OncePerRequestFilter {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(HttpRequestResponseLoggingFilter.class);
 
+    private static final String DICTIONARY_API_PATH_PREFIX = "/api/dictionary/";
     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*\\\")[^\\\"]*(\\\")",
+            "(\\\"(?:password|secret|token|authorization|accessToken|systemToken|key|idCardNo|id_card_no)\\\"\\s*:\\s*\\\")[^\\\"]*(\\\")",
             Pattern.CASE_INSENSITIVE);
     private static final Pattern SENSITIVE_QUERY_PARAMETER = Pattern.compile(
-            "((?:password|secret|token|authorization|accessToken|systemToken|key)=)[^&]*",
+            "((?:password|secret|token|authorization|accessToken|systemToken|key|idCardNo|id_card_no)=)[^&]*",
             Pattern.CASE_INSENSITIVE);
 
     /**
@@ -40,6 +41,11 @@ public class HttpRequestResponseLoggingFilter extends OncePerRequestFilter {
      */
     @Override
     protected boolean shouldNotFilter(HttpServletRequest request) {
+        // 数据字典可能返回大量记录,暂时关闭该接口的请求和响应日志输出。
+        if (request.getRequestURI().startsWith(DICTIONARY_API_PATH_PREFIX)) {
+            return true;
+        }
+
         String contentType = request.getContentType();
         return contentType != null && (contentType.startsWith("multipart/")
                 || contentType.startsWith("application/octet-stream"));

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

@@ -34,6 +34,7 @@ public class YidaApplicationsProperties {
 
         private String appType;
         private String systemToken;
+        private String processInstanceQueryUserId = "41603502201288023";
 
         public String getAppType() {
             return appType;
@@ -50,5 +51,14 @@ public class YidaApplicationsProperties {
         public void setSystemToken(String systemToken) {
             this.systemToken = systemToken;
         }
+
+        public String getProcessInstanceQueryUserId() {
+            return processInstanceQueryUserId;
+        }
+
+        public void setProcessInstanceQueryUserId(String processInstanceQueryUserId) {
+            this.processInstanceQueryUserId = processInstanceQueryUserId;
+        }
+
     }
 }

+ 131 - 2
src/main/java/com/malk/minglei/controller/BankingController.java

@@ -4,9 +4,16 @@ import com.malk.minglei.dto.ApiResponse;
 import com.malk.minglei.dto.banking.BankBranchQueryRequest;
 import com.malk.minglei.dto.banking.BankTransactionQueryRequest;
 import com.malk.minglei.dto.banking.CurrentBalanceQueryRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadResult;
 import com.malk.minglei.dto.banking.SinglePaymentRequest;
+import com.malk.minglei.config.BankingProperties;
 import com.malk.minglei.service.banking.BankingService;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.ModelAttribute;
 import org.springframework.web.bind.annotation.PathVariable;
@@ -14,7 +21,15 @@ import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriUtils;
 
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.Map;
 
 /**
@@ -23,15 +38,19 @@ import java.util.Map;
 @RestController
 public class BankingController {
 
+    private static final long MAX_PREVIEW_SIZE = 50L * 1024 * 1024;
+
     private final BankingService bankingService;
+    private final BankingProperties bankingProperties;
 
     /**
      * 创建银企直连控制器。
      *
      * @param bankingService 银企直连服务
      */
-    public BankingController(BankingService bankingService) {
+    public BankingController(BankingService bankingService, BankingProperties bankingProperties) {
         this.bankingService = bankingService;
+        this.bankingProperties = bankingProperties;
     }
 
     /**
@@ -79,7 +98,17 @@ public class BankingController {
     @PostMapping("/api/banking/payments/submit-and-query-status")
     public ResponseEntity<ApiResponse<Map<String, Object>>> submitSinglePaymentAndQueryStatus(
             @RequestBody SinglePaymentRequest request) {
-        return ResponseEntity.ok(ApiResponse.success(bankingService.submitSinglePaymentAndQueryStatus(request)));
+        Map<String, Object> result = new java.util.LinkedHashMap<String, Object>(
+                bankingService.submitSinglePaymentAndQueryStatus(request));
+        Object electronicBill = result.get("electronicBill");
+        if (electronicBill instanceof ElectronicBillDownloadResult) {
+            ElectronicBillDownloadResult receipt = (ElectronicBillDownloadResult) electronicBill;
+            result.put("electronicBill", new ElectronicBillDownloadResult(
+                    receipt.getFileName(), receipt.getFilePath(), receipt.getFileSize(), receipt.getContentType(),
+                    buildElectronicBillFileUrl(receipt.getFileName(), "preview"),
+                    buildElectronicBillFileUrl(receipt.getFileName(), "download")));
+        }
+        return ResponseEntity.ok(ApiResponse.success(result));
     }
 
     /**
@@ -117,4 +146,104 @@ public class BankingController {
             @ModelAttribute BankBranchQueryRequest request) {
         return ResponseEntity.ok(ApiResponse.success(bankingService.searchBankBranches(request)));
     }
+
+    /**
+     * 下载电子回单并保存到服务器文件目录,响应中仅返回保存后的文件信息。
+     *
+     * @param request 回单唯一流水号或付款业务流水号
+     * @return 本地保存的文件名、路径、大小和内容类型
+     */
+    @GetMapping({"/api/banking/electronic-bills/download", "/api/v2/bs/ebill/download"})
+    public ResponseEntity<ApiResponse<ElectronicBillDownloadResult>> downloadElectronicBill(
+            @ModelAttribute ElectronicBillDownloadRequest request) {
+        ElectronicBillDownloadResult result = bankingService.downloadElectronicBill(request);
+        ElectronicBillDownloadResult response = new ElectronicBillDownloadResult(
+                result.getFileName(), result.getFilePath(), result.getFileSize(), result.getContentType(),
+                buildElectronicBillFileUrl(result.getFileName(), "preview"),
+                buildElectronicBillFileUrl(result.getFileName(), "download"));
+        return ResponseEntity.ok(ApiResponse.success(response));
+    }
+
+    /**
+     * 以预览或下载模式输出已保存的电子回单文件。
+     *
+     * @param fileId 下载回单接口返回的文件名
+     * @param option preview 为内联预览,其他值按附件下载处理
+     * @return 文件二进制流
+     */
+    @GetMapping("/api/banking/electronic-bills/files/{fileId:.+}")
+    public ResponseEntity<StreamingResponseBody> getElectronicBillFile(
+            @PathVariable String fileId,
+            @RequestParam(defaultValue = "download") String option) {
+        Path filePath = resolveElectronicBillFile(fileId);
+        if (filePath == null || !Files.isRegularFile(filePath)) {
+            return ResponseEntity.notFound().build();
+        }
+        try {
+            long fileSize = Files.size(filePath);
+            boolean preview = "preview".equalsIgnoreCase(option);
+            if (preview && fileSize > MAX_PREVIEW_SIZE) {
+                return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).build();
+            }
+
+            String fileName = filePath.getFileName().toString();
+            String encodedFileName = encodeFileName(fileName);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(preview ? resolveContentType(filePath) : MediaType.APPLICATION_OCTET_STREAM);
+            headers.add(HttpHeaders.CONTENT_DISPOSITION, (preview ? "inline" : "attachment")
+                    + "; filename=\"" + encodedFileName + "\"; filename*=UTF-8''" + encodedFileName);
+            headers.add(HttpHeaders.CACHE_CONTROL, preview ? "max-age=300" : "no-cache");
+            headers.add("X-Content-Type-Options", "nosniff");
+            headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
+            if (!preview) {
+                headers.add(HttpHeaders.ACCEPT_RANGES, "bytes");
+            }
+
+            StreamingResponseBody responseBody = outputStream -> {
+                try (InputStream inputStream = Files.newInputStream(filePath)) {
+                    byte[] buffer = new byte[8192];
+                    int bytesRead;
+                    while ((bytesRead = inputStream.read(buffer)) != -1) {
+                        outputStream.write(buffer, 0, bytesRead);
+                    }
+                }
+            };
+            return ResponseEntity.ok().headers(headers).contentLength(fileSize).body(responseBody);
+        } catch (IOException exception) {
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+        }
+    }
+
+    private Path resolveElectronicBillFile(String fileId) {
+        if (fileId == null || fileId.trim().isEmpty()) {
+            return null;
+        }
+        Path directory = Paths.get(bankingProperties.getReceiptFileStoragePath()).toAbsolutePath().normalize();
+        Path filePath = directory.resolve(fileId).normalize();
+        return directory.equals(filePath.getParent()) ? filePath : null;
+    }
+
+    private MediaType resolveContentType(Path filePath) throws IOException {
+        String fileName = filePath.getFileName().toString().toLowerCase();
+        if (fileName.endsWith(".pdf")) return MediaType.APPLICATION_PDF;
+        if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) return MediaType.IMAGE_JPEG;
+        if (fileName.endsWith(".png")) return MediaType.IMAGE_PNG;
+        if (fileName.endsWith(".gif")) return MediaType.IMAGE_GIF;
+        String contentType = Files.probeContentType(filePath);
+        return contentType == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(contentType);
+    }
+
+    private String buildElectronicBillFileUrl(String fileName, String option) {
+        return bankingProperties.getPublicBaseUrl().replaceAll("/+$", "")
+                + "/api/banking/electronic-bills/files/" + UriUtils.encodePathSegment(fileName, StandardCharsets.UTF_8)
+                + "?option=" + option;
+    }
+
+    private String encodeFileName(String fileName) {
+        try {
+            return URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20");
+        } catch (Exception exception) {
+            throw new IllegalStateException("电子回单文件名编码失败", exception);
+        }
+    }
 }

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

@@ -1,11 +1,14 @@
 package com.malk.minglei.controller;
 
 import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.DingTalkDedicatedAccountCreateRequest;
+import com.malk.minglei.dto.DingTalkDedicatedAccountCreateResult;
 import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.EmployeeRosterQueryRequest;
 import com.malk.minglei.dto.EmployeeRosterSyncResult;
 import com.malk.minglei.service.employee.EmployeeRosterService;
 import org.springframework.http.ResponseEntity;
+import org.springframework.http.HttpStatus;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -36,6 +39,19 @@ public class EmployeeRosterController {
         this.employeeRosterService = employeeRosterService;
     }
 
+    /**
+     * 创建钉钉自建企业账号。
+     *
+     * @param request 已完成字段校验的账号创建请求
+     * @return 钉钉返回的用户 ID 和实际提交的部门 ID 列表
+     */
+    @PostMapping("/dingtalk-dedicated-accounts")
+    public ResponseEntity<ApiResponse<DingTalkDedicatedAccountCreateResult>> createDingTalkDedicatedAccount(
+            @Valid @RequestBody DingTalkDedicatedAccountCreateRequest request) {
+        return ResponseEntity.status(HttpStatus.CREATED)
+                .body(ApiResponse.success(employeeRosterService.createDingTalkDedicatedAccount(request)));
+    }
+
     /**
      * 按指定部门同步钉钉员工花名册。
      *

+ 37 - 2
src/main/java/com/malk/minglei/controller/GlobalExceptionHandler.java

@@ -10,6 +10,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 
+import java.io.PrintWriter;
+import java.io.StringWriter;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -81,14 +83,47 @@ public class GlobalExceptionHandler {
      */
     @ExceptionHandler(Exception.class)
     public ResponseEntity<Map<String, String>> handleUnexpected(Exception exception) {
-        LOGGER.error("Yida client request failed", exception);
+        Throwable rootCause = rootCause(exception);
+        String exceptionMessage = sanitizeExceptionMessage(exception.getMessage());
+        String rootCauseMessage = sanitizeExceptionMessage(rootCause.getMessage());
+        LOGGER.error("Yida client request failed exceptionType={} exceptionMessage={} rootCauseType={} rootCauseMessage={} stackTrace={}",
+                exception.getClass().getName(), exceptionMessage, rootCause.getClass().getName(), rootCauseMessage,
+                sanitizeStackTrace(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.");
+        response.put("message", "Yida client request failed: " + exception.getClass().getSimpleName()
+                + ": " + defaultText(exceptionMessage, "no message"));
+        response.put("exceptionType", exception.getClass().getName());
+        response.put("rootCause", rootCause.getClass().getName() + ": "
+                + defaultText(rootCauseMessage, "no message"));
         return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(response);
     }
 
+    private Throwable rootCause(Throwable exception) {
+        Throwable current = exception;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        return current;
+    }
+
+    private String sanitizeExceptionMessage(String message) {
+        if (message == null) {
+            return null;
+        }
+        String sanitized = message
+                .replaceAll("(?i)(systemToken=)[^&\\s]+", "$1******")
+                .replaceAll("(?i)(x-acs-dingtalk-access-token[:=])[^\\s,]+", "$1******");
+        return sanitized.length() <= 2048 ? sanitized : sanitized.substring(0, 2048) + "...<truncated>";
+    }
+
+    private String sanitizeStackTrace(Throwable exception) {
+        StringWriter stackTrace = new StringWriter();
+        exception.printStackTrace(new PrintWriter(stackTrace));
+        return sanitizeExceptionMessage(stackTrace.toString());
+    }
+
     private String defaultText(String value, String fallback) {
         return value == null || value.trim().isEmpty() ? fallback : value.trim();
     }

+ 16 - 2
src/main/java/com/malk/minglei/controller/MingleiPortController.java

@@ -7,6 +7,7 @@ import org.slf4j.LoggerFactory;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.FieldError;
 import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -14,6 +15,7 @@ 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 org.springframework.util.StringUtils;
 
 import javax.validation.Valid;
 import java.util.Collections;
@@ -49,8 +51,13 @@ public class MingleiPortController {
     @PostMapping("/supplier-price-approvals")
     public ResponseEntity<Map<String, Object>> submitSupplierPriceApproval(
             @Valid @RequestBody SupplierPriceApprovalProcessRequest request) {
+        if (request.requiresProcessInstanceId() && !StringUtils.hasText(request.getProcessInstanceId())) {
+            return ResponseEntity.badRequest().body(Collections.<String, Object>singletonMap("message",
+                    "Request validation failed: processInstanceId must not be blank for supplier price approval forms"));
+        }
         return ResponseEntity.ok(supplierPriceApprovalProcessService
-                .submitByProcessInstanceId(request.getFormId(), request.getProcessInstanceId()));
+                .submitByInstanceIds(request.getFormId(), request.getFormInstanceId(),
+                        request.getProcessInstanceId()));
     }
 
     /**
@@ -61,7 +68,14 @@ public class MingleiPortController {
      */
     @ExceptionHandler({MethodArgumentNotValidException.class, HttpMessageNotReadableException.class})
     public ResponseEntity<Map<String, String>> handleInvalidRequest(Exception exception) {
-        return ResponseEntity.badRequest().body(Collections.singletonMap("message", "Request validation failed"));
+        if (exception instanceof MethodArgumentNotValidException) {
+            FieldError fieldError = ((MethodArgumentNotValidException) exception).getBindingResult().getFieldError();
+            if (fieldError != null) {
+                return ResponseEntity.badRequest().body(Collections.singletonMap("message",
+                        "Request validation failed: " + fieldError.getField() + " " + fieldError.getDefaultMessage()));
+            }
+        }
+        return ResponseEntity.badRequest().body(Collections.singletonMap("message", "Request body is invalid"));
     }
 
     /**

+ 21 - 3
src/main/java/com/malk/minglei/controller/YidaProcessController.java

@@ -6,6 +6,7 @@ import com.malk.minglei.dto.DepartmentUserIdsResponse;
 import com.malk.minglei.dto.ApiResponse;
 import com.malk.minglei.dto.YidaProcessStartRequest;
 import com.malk.minglei.service.yida.MingleiDocumentService;
+import com.malk.minglei.service.yida.YidaProcessInstanceClient;
 import com.malk.minglei.service.yida.YidaProcessService;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
@@ -13,6 +14,7 @@ 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.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -30,7 +32,7 @@ import java.util.Map;
 import java.util.Set;
 
 /**
- * 宜搭流程发起、单据查询和部门成员查询接口。
+ * 宜搭流程发起、实例查询、单据查询和部门成员查询接口。
  */
 @Validated
 @RestController
@@ -39,16 +41,20 @@ public class YidaProcessController {
 
     private final YidaProcessService yidaProcessService;
     private final MingleiDocumentService mingleiDocumentService;
-
+    private final YidaProcessInstanceClient yidaProcessInstanceClient;
     /**
      * 创建宜搭业务接口控制器。
      *
      * @param yidaProcessService 宜搭流程服务
      * @param mingleiDocumentService 明磊单据查询服务
+     * @param yidaProcessInstanceClient 宜搭流程实例查询客户端
      */
-    public YidaProcessController(YidaProcessService yidaProcessService, MingleiDocumentService mingleiDocumentService) {
+    public YidaProcessController(YidaProcessService yidaProcessService,
+                                 MingleiDocumentService mingleiDocumentService,
+                                 YidaProcessInstanceClient yidaProcessInstanceClient) {
         this.yidaProcessService = yidaProcessService;
         this.mingleiDocumentService = mingleiDocumentService;
+        this.yidaProcessInstanceClient = yidaProcessInstanceClient;
     }
 
 
@@ -65,6 +71,18 @@ public class YidaProcessController {
                 .body(ApiResponse.success(Collections.singletonMap("processInstanceId", processInstanceId)));
     }
 
+    /**
+     * 按宜搭流程实例 ID 查询实例原始数据,仅用于排查流程数据和字段映射问题,不会触发 ERP 推送。
+     *
+     * @param processInstanceId 宜搭流程实例 ID
+     * @return 钉钉宜搭接口返回的流程实例数据
+     */
+    @GetMapping("/process-instances/{processInstanceId}")
+    public ResponseEntity<Map<String, Object>> getProcessInstance(
+            @PathVariable String processInstanceId) {
+        return ResponseEntity.ok(yidaProcessInstanceClient.getProcessInstance(processInstanceId));
+    }
+
     /**
      * 返回流程类型不支持等本地请求参数错误。
      *

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

@@ -1,5 +1,7 @@
 package com.malk.minglei.dto;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
+
 /**
  * 员工花名册查询结果。
  */
@@ -15,6 +17,11 @@ public class EmployeeRosterInfo {
     private String jobGradeId;
     private String department;
     private String firstDepartment;
+    private String hireDate;
+    private String positionName;
+    private String bankCardNo;
+    private String bankName;
+    private String bankBranchCode;
 
     public Integer getId() {
         return id;
@@ -95,4 +102,47 @@ public class EmployeeRosterInfo {
     public void setFirstDepartment(String firstDepartment) {
         this.firstDepartment = firstDepartment;
     }
+
+    public String getHireDate() {
+        return hireDate;
+    }
+
+    public void setHireDate(String hireDate) {
+        this.hireDate = hireDate;
+    }
+
+    public String getPositionName() {
+        return positionName;
+    }
+
+    public void setPositionName(String positionName) {
+        this.positionName = positionName;
+    }
+
+    @JsonProperty("bank_card_no")
+    public String getBankCardNo() {
+        return bankCardNo;
+    }
+
+    public void setBankCardNo(String bankCardNo) {
+        this.bankCardNo = bankCardNo;
+    }
+
+    @JsonProperty("bank_name")
+    public String getBankName() {
+        return bankName;
+    }
+
+    public void setBankName(String bankName) {
+        this.bankName = bankName;
+    }
+
+    @JsonProperty("bank_branch_code")
+    public String getBankBranchCode() {
+        return bankBranchCode;
+    }
+
+    public void setBankBranchCode(String bankBranchCode) {
+        this.bankBranchCode = bankBranchCode;
+    }
 }

+ 49 - 3
src/main/java/com/malk/minglei/dto/SupplierPriceApprovalProcessRequest.java

@@ -1,16 +1,35 @@
 package com.malk.minglei.dto;
 
 import javax.validation.constraints.NotBlank;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
 
 /**
  * 宜搭供应商价格审批流程推送 T100 的请求参数。
  */
-public class SupplierPriceApprovalProcessRequest {
+public class
+
+SupplierPriceApprovalProcessRequest {
+
+    private static final Set<String> PROCESS_INSTANCE_ID_REQUIRED_FORM_IDS = Collections.unmodifiableSet(
+            new HashSet<String>(Arrays.asList(
+                    "FORM-468B6FB2D5BF490A8BC6DBD6FAF9BCCA28R9",
+                    "FORM-F906C997A4A44DC8B9180198C0B2E55F6W0S",
+                    "FORM-C09818E73B1640A9AA9F6D7F55C47612EFV8",
+                    "FORM-EC46C7109182497796AA0B70F3848576UYBU")));
 
     @NotBlank
     private String formId;
 
     @NotBlank
+    private String formInstanceId;
+
+    /**
+     * 宜搭流程实例 ID。四张供应商价格审批单在 T100 成功后使用该 ID 回写 ERP 单号;
+     * 供应商暂停/恢复流程无需传入该字段。
+     */
     private String processInstanceId;
 
     /**
@@ -31,10 +50,28 @@ public class SupplierPriceApprovalProcessRequest {
         this.formId = formId;
     }
 
+    /**
+     * 获取宜搭表单实例 ID。
+     *
+     * @return 宜搭表单实例 ID
+     */
+    public String getFormInstanceId() {
+        return formInstanceId;
+    }
+
+    /**
+     * 设置宜搭表单实例 ID。
+     *
+     * @param formInstanceId 宜搭表单实例 ID
+     */
+    public void setFormInstanceId(String formInstanceId) {
+        this.formInstanceId = formInstanceId;
+    }
+
     /**
      * 获取宜搭流程实例 ID。
      *
-     * @return 宜搭流程实例 ID
+     * @return 宜搭流程实例 ID;未传入时为 {@code null}
      */
     public String getProcessInstanceId() {
         return processInstanceId;
@@ -43,9 +80,18 @@ public class SupplierPriceApprovalProcessRequest {
     /**
      * 设置宜搭流程实例 ID。
      *
-     * @param processInstanceId 宜搭流程实例 ID
+     * @param processInstanceId 宜搭流程实例 ID,可不传
      */
     public void setProcessInstanceId(String processInstanceId) {
         this.processInstanceId = processInstanceId;
     }
+
+    /**
+     * 判断当前表单是否必须提供宜搭流程实例 ID。
+     *
+     * @return 四张供应商价格审批单返回 {@code true},其他表单返回 {@code false}
+     */
+    public boolean requiresProcessInstanceId() {
+        return PROCESS_INSTANCE_ID_REQUIRED_FORM_IDS.contains(formId);
+    }
 }

+ 13 - 0
src/main/java/com/malk/minglei/service/approval/DingTalkApprovalClient.java

@@ -15,4 +15,17 @@ public interface DingTalkApprovalClient {
      * @return 审批实例详情响应,包含 success 和 result 节点
      */
     Map<String, Object> getProcessInstanceDetail(String accessToken, String processInstanceId);
+
+    /**
+     * 根据宜搭流程实例 ID 查询实例详情。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param processInstanceId 宜搭流程实例 ID
+     * @param appType 宜搭应用 ID
+     * @param systemToken 宜搭应用密钥
+     * @param userId 用于数据权限校验的钉钉用户 ID
+     * @return 宜搭流程实例原始响应,表单数据位于顶层 data 节点
+     */
+    Map<String, Object> getYidaProcessInstanceDetail(String accessToken, String processInstanceId,
+                                                      String appType, String systemToken, String userId);
 }

+ 4 - 0
src/main/java/com/malk/minglei/service/banking/BankingService.java

@@ -3,6 +3,8 @@ package com.malk.minglei.service.banking;
 import com.malk.minglei.dto.banking.BankBranchQueryRequest;
 import com.malk.minglei.dto.banking.BankTransactionQueryRequest;
 import com.malk.minglei.dto.banking.CurrentBalanceQueryRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadResult;
 import com.malk.minglei.dto.banking.SinglePaymentRequest;
 
 import java.util.Map;
@@ -23,4 +25,6 @@ public interface BankingService {
     Map<String, Object> queryPaymentStatus(String businessTransNo);
     /** 查询银行支行信息。 */
     Map<String, Object> searchBankBranches(BankBranchQueryRequest request);
+    /** 下载电子回单并保存至服务器本地目录。*/
+    ElectronicBillDownloadResult downloadElectronicBill(ElectronicBillDownloadRequest request);
 }

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

@@ -2,12 +2,22 @@ package com.malk.minglei.service.employee;
 
 import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.EmployeeRosterSyncResult;
+import com.malk.minglei.dto.DingTalkDedicatedAccountCreateRequest;
+import com.malk.minglei.dto.DingTalkDedicatedAccountCreateResult;
 
 /**
  * 员工花名册同步和查询服务。
  */
 public interface EmployeeRosterService {
 
+    /**
+     * 创建钉钉自建企业账号。
+     *
+     * @param request 账号创建请求
+     * @return 钉钉创建结果
+     */
+    DingTalkDedicatedAccountCreateResult createDingTalkDedicatedAccount(DingTalkDedicatedAccountCreateRequest request);
+
     /**
      * 按指定部门和查询范围同步员工花名册。
      *
@@ -39,4 +49,12 @@ public interface EmployeeRosterService {
      * @return 员工花名册信息,不存在时返回 null
      */
     EmployeeRosterInfo findByEmployeeNo(String employeeNo);
+
+    /**
+     * 根据员工姓名查询在职员工花名册。
+     *
+     * @param username 员工姓名
+     * @return 员工花名册信息,不存在时返回 null
+     */
+    EmployeeRosterInfo findByUsername(String username);
 }

+ 35 - 0
src/main/java/com/malk/minglei/service/impl/approval/DingTalkApprovalClientImpl.java

@@ -26,6 +26,11 @@ public class DingTalkApprovalClientImpl implements DingTalkApprovalClient {
     private static final String PROCESS_INSTANCE_DETAIL_URL =
             "https://api.dingtalk.com/v1.0/workflow/processInstances?processInstanceId={processInstanceId}";
 
+    private static final String YIDA_PROCESS_INSTANCE_DETAIL_URL =
+            "https://api.dingtalk.com/v2.0/yida/processes/instancesInfos/{processInstanceId}"
+                    + "?appType={appType}&systemToken={systemToken}&userId={userId}"
+                    + "&language=zh_CN&useAlias=false";
+
     private static final String ACCESS_TOKEN_HEADER = "x-acs-dingtalk-access-token";
 
     private static final String ERROR_CODE = "DINGTALK_APPROVAL_CALL_FAILED";
@@ -77,6 +82,36 @@ public class DingTalkApprovalClientImpl implements DingTalkApprovalClient {
         }
     }
 
+    /**
+     * 调用宜搭 GetInstanceById v2 接口。useAlias 固定为 false,确保 data 使用字段 ID,
+     * 从而与 ERP 推送已有的字段映射保持一致。
+     */
+    @Override
+    public Map<String, Object> getYidaProcessInstanceDetail(String accessToken, String processInstanceId,
+                                                             String appType, String systemToken, String userId) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
+        headers.set(ACCESS_TOKEN_HEADER, accessToken);
+
+        try {
+            ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
+                    YIDA_PROCESS_INSTANCE_DETAIL_URL,
+                    HttpMethod.GET,
+                    new HttpEntity<Map<String, Object>>(headers),
+                    new ParameterizedTypeReference<Map<String, Object>>() {
+                    },
+                    processInstanceId, appType, systemToken, userId);
+
+            Map<String, Object> responseBody = response.getBody();
+            if (responseBody == null) {
+                throw new McException(ERROR_CODE, "Yida process instance response is empty");
+            }
+            return responseBody;
+        } catch (HttpStatusCodeException exception) {
+            throw new McException(ERROR_CODE, extractErrorMessage(exception));
+        }
+    }
+
     private String extractErrorMessage(HttpStatusCodeException exception) {
         String message = parseDingTalkMessage(exception.getResponseBodyAsString());
         if (message != null) {

+ 150 - 7
src/main/java/com/malk/minglei/service/impl/banking/BankingServiceImpl.java

@@ -11,6 +11,8 @@ import com.malk.minglei.config.BankingProperties;
 import com.malk.minglei.dto.banking.BankBranchQueryRequest;
 import com.malk.minglei.dto.banking.BankTransactionQueryRequest;
 import com.malk.minglei.dto.banking.CurrentBalanceQueryRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadResult;
 import com.malk.minglei.dto.banking.SinglePaymentRequest;
 import com.malk.minglei.service.banking.BankingService;
 import org.slf4j.Logger;
@@ -29,6 +31,10 @@ import java.math.BigDecimal;
 import java.net.URI;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
 import java.security.SecureRandom;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -49,6 +55,7 @@ public class BankingServiceImpl implements BankingService {
     private static final String PAYMENT_PATH = "/api/v2/payment/commonPay";
     private static final String PAYMENT_STATUS_PATH = "/api/v2/payment/transferStatus";
     private static final String BANK_BRANCH_PATH = "/api/v2/bank/searchBranch";
+    private static final String ELECTRONIC_BILL_DOWNLOAD_PATH = "/api/v2/bs/ebill/download";
     private static final char[] SM4_KEY_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray();
     private static final Pattern BRANCH_UPDATE_TIME_PATTERN =
             Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}");
@@ -137,7 +144,7 @@ public class BankingServiceImpl implements BankingService {
     }
 
     /**
-     * 先发起制单,再使用请求中的业务流水号查询该笔支付状态。
+     * 先发起制单,再使用请求中的业务流水号查询该笔支付状态;支付成功后下载回单。
      * 制单调用发生异常时会直接终止,避免将未成功发起的流水号误作状态查询。
      *
      * @param request 单笔支付制单参数
@@ -149,15 +156,46 @@ public class BankingServiceImpl implements BankingService {
         Map<String, Object> result = new LinkedHashMap<String, Object>();
         result.put("submitResponse", submitResponse);
         if (isSuccessfulBankResponse(submitResponse)) {
-            result.put("paymentStatus", queryPaymentStatus(request.getBusinessTransNo()));
+            Map<String, Object> paymentStatus = queryPaymentStatus(request.getBusinessTransNo());
+            result.put("paymentStatus", paymentStatus);
             result.put("statusQuerySkipped", false);
+            downloadElectronicBillAfterSuccessfulPayment(request.getBusinessTransNo(), paymentStatus, result);
         } else {
             result.put("paymentStatus", null);
             result.put("statusQuerySkipped", true);
+            result.put("electronicBill", null);
+            result.put("receiptDownloadSkipped", true);
+            result.put("receiptDownloadMessage", "制单未成功,未查询支付状态和下载回单");
         }
         return result;
     }
 
+    /**
+     * 回单仅在银行确认支付成功后下载。回单生成可能存在延迟,因此下载失败不影响制单和状态查询结果。
+     */
+    private void downloadElectronicBillAfterSuccessfulPayment(String businessTransNo,
+                                                              Map<String, Object> paymentStatus,
+                                                              Map<String, Object> result) {
+        if (!isPaymentSuccessful(paymentStatus)) {
+            result.put("electronicBill", null);
+            result.put("receiptDownloadSkipped", true);
+            result.put("receiptDownloadMessage", "支付状态不是 X0000,暂不下载回单");
+            return;
+        }
+        try {
+            ElectronicBillDownloadRequest receiptRequest = new ElectronicBillDownloadRequest();
+            receiptRequest.setBusinessTransNo(businessTransNo);
+            result.put("electronicBill", downloadElectronicBill(receiptRequest));
+            result.put("receiptDownloadSkipped", false);
+            result.put("receiptDownloadMessage", null);
+        } catch (RuntimeException exception) {
+            result.put("electronicBill", null);
+            result.put("receiptDownloadSkipped", true);
+            result.put("receiptDownloadMessage", "支付成功,但回单暂不可下载:" + exception.getMessage());
+            LOGGER.warn("Electronic bill is not available after successful payment");
+        }
+    }
+
     /**
      * 按外部交易流水号查询单笔支付状态。
      *
@@ -200,6 +238,41 @@ public class BankingServiceImpl implements BankingService {
         return branchName == null ? response : filterBankBranches(response, bankPrefix, branchName);
     }
 
+    /**
+     * 从 NexBank 下载电子回单二进制文件,并保存至服务器配置的目录。
+     *
+     * @param request 回单唯一流水号或付款业务流水号,二者必须且只能传一个
+     * @return 已保存的本地文件信息
+     */
+    @Override
+    public ElectronicBillDownloadResult downloadElectronicBill(ElectronicBillDownloadRequest request) {
+        requireRequest(request, "电子回单下载请求不能为空");
+        boolean hasUniqueNumber = StringUtils.hasText(request.getUniqueNumber());
+        boolean hasBusinessTransNo = StringUtils.hasText(request.getBusinessTransNo());
+        if (hasUniqueNumber == hasBusinessTransNo) {
+            throw new IllegalArgumentException("uniqueNumber 与 businessTransNo 必须且只能传入一个");
+        }
+
+        Map<String, String> query = new LinkedHashMap<String, String>();
+        query.put("uniqueNumber", request.getUniqueNumber());
+        query.put("businessTransNo", request.getBusinessTransNo());
+        URI uri = createReceiptGetUri(ELECTRONIC_BILL_DOWNLOAD_PATH, query);
+        ResponseEntity<byte[]> response = restTemplate.exchange(
+                uri, HttpMethod.GET, new HttpEntity<Void>(createGetHeaders()), byte[].class);
+        byte[] content = response.getBody();
+        if (content == null || content.length == 0) {
+            throw new IllegalStateException("银企直连未返回电子回单文件内容");
+        }
+
+        String fallbackFileName = (hasUniqueNumber ? request.getUniqueNumber() : request.getBusinessTransNo()) + ".pdf";
+        String fileName = resolveReceiptFileName(response.getHeaders(), fallbackFileName);
+        Path savedFile = saveReceiptFile(fileName, content);
+        MediaType contentType = response.getHeaders().getContentType();
+        LOGGER.info("Electronic bill saved fileName={} fileSize={}", savedFile.getFileName(), content.length);
+        return new ElectronicBillDownloadResult(savedFile.getFileName().toString(), savedFile.toString(), content.length,
+                contentType == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : contentType.toString());
+    }
+
     private Map<String, Object> postEncryptedPayment(Map<String, Object> payment) {
         require(properties.getXencioPublicKey(), "MINGLEI_BANKING_XENCIO_PUBLIC_KEY 未配置");
         require(properties.getEnterprisePrivateKey(), "MINGLEI_BANKING_ENTERPRISE_PRIVATE_KEY 未配置");
@@ -281,6 +354,15 @@ public class BankingServiceImpl implements BankingService {
         return successResponse == null || Boolean.parseBoolean(String.valueOf(successResponse));
     }
 
+    private boolean isPaymentSuccessful(Map<String, Object> paymentStatus) {
+        Object data = paymentStatus.get("data");
+        if (!(data instanceof Map)) {
+            return false;
+        }
+        Object transferStatus = ((Map<?, ?>) data).get("transferStatus");
+        return "X0000".equals(String.valueOf(transferStatus));
+    }
+
     /**
      * 支付请求优先使用调用方指定的付款账号,缺失时使用银企配置中的默认付款账号。
      *
@@ -464,7 +546,26 @@ public class BankingServiceImpl implements BankingService {
     }
 
     private Map<String, Object> get(String path, Map<String, String> query) {
-        StringBuilder url = new StringBuilder(apiUrl(path));
+        URI uri = createGetUri(path, query);
+        ResponseEntity<String> response = restTemplate.exchange(
+                uri, HttpMethod.GET, new HttpEntity<Void>(createGetHeaders()), String.class);
+        return parseResponse(response.getBody());
+    }
+
+    private URI createGetUri(String path, Map<String, String> query) {
+        return createGetUriFromBaseUrl(apiUrl(path), query);
+    }
+
+    /**
+     * 回单下载使用正式环境地址,避免测试环境的支付、流水等接口被切换到生产环境。
+     */
+    private URI createReceiptGetUri(String path, Map<String, String> query) {
+        require(properties.getReceiptBaseUrl(), "MINGLEI_BANKING_RECEIPT_BASE_URL 未配置");
+        return createGetUriFromBaseUrl(properties.getReceiptBaseUrl().replaceAll("/+$", "") + path, query);
+    }
+
+    private URI createGetUriFromBaseUrl(String baseUrl, Map<String, String> query) {
+        StringBuilder url = new StringBuilder(baseUrl);
         boolean hasQueryParameter = false;
         for (Map.Entry<String, String> entry : query.entrySet()) {
             if (StringUtils.hasText(entry.getValue())) {
@@ -475,10 +576,52 @@ public class BankingServiceImpl implements BankingService {
                 hasQueryParameter = true;
             }
         }
-        URI uri = URI.create(url.toString());
-        ResponseEntity<String> response = restTemplate.exchange(
-                uri, HttpMethod.GET, new HttpEntity<Void>(createGetHeaders()), String.class);
-        return parseResponse(response.getBody());
+        return URI.create(url.toString());
+    }
+
+    /**
+     * 使用上游响应的附件文件名;无法获取时以查询流水号生成 PDF 文件名。
+     */
+    private String resolveReceiptFileName(HttpHeaders headers, String fallbackFileName) {
+        String fileName = headers.getContentDisposition().getFilename();
+        if (!StringUtils.hasText(fileName)) {
+            fileName = fallbackFileName;
+        }
+        String sanitizedFileName = fileName.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_").trim();
+        if (!StringUtils.hasText(sanitizedFileName) || ".".equals(sanitizedFileName) || "..".equals(sanitizedFileName)) {
+            return "electronic-bill.pdf";
+        }
+        return sanitizedFileName;
+    }
+
+    /**
+     * 创建存储目录并以新文件方式写入,重名时追加序号,避免覆盖既有回单。
+     */
+    private Path saveReceiptFile(String fileName, byte[] content) {
+        require(properties.getReceiptFileStoragePath(), "MINGLEI_BANKING_RECEIPT_FILE_STORAGE_PATH 未配置");
+        try {
+            Path directory = java.nio.file.Paths.get(properties.getReceiptFileStoragePath()).toAbsolutePath().normalize();
+            Files.createDirectories(directory);
+            int extensionIndex = fileName.lastIndexOf('.');
+            String name = extensionIndex > 0 ? fileName.substring(0, extensionIndex) : fileName;
+            String extension = extensionIndex > 0 ? fileName.substring(extensionIndex) : "";
+            for (int index = 0; index < 1000; index++) {
+                String candidateFileName = index == 0 ? fileName : name + "-" + index + extension;
+                Path candidate = directory.resolve(candidateFileName).normalize();
+                if (!candidate.getParent().equals(directory)) {
+                    throw new IllegalArgumentException("电子回单文件名不合法");
+                }
+                try {
+                    Files.write(candidate, content, StandardOpenOption.CREATE_NEW);
+                    return candidate;
+                } catch (FileAlreadyExistsException ignored) {
+                    // 同名回单保留原文件,使用下一个可用文件名。
+                }
+            }
+            throw new IllegalStateException("电子回单文件名冲突次数过多");
+        } catch (java.io.IOException exception) {
+            throw new IllegalStateException("电子回单文件保存失败", exception);
+        }
     }
 
     /**

+ 129 - 8
src/main/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImpl.java

@@ -53,6 +53,14 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
     private static final String ERP_PACKAGING_MATERIAL_COUNT_SQL = "select count(1) from DSDATA.pmaauc_t " +
             "where pmaaucent = :ent and pmaauc016 is not null";
 
+    // 原单价按材料编号和公司精确定位,保留 ERP 原始字段名称供宜搭直接取值。
+    private static final String ERP_MATERIAL_ORIGINAL_PRICE_DICTIONARY_CODE = "erp-material-original-price";
+    private static final String ERP_MATERIAL_ORIGINAL_PRICE_DATA_SQL =
+            "select pmaauc002, pmaauc015, pmaauc001, pmaaucent " +
+                    "from DSDATA.pmaauc_t where pmaauc001 = :czbh and pmaaucent = :ent";
+    private static final String ERP_MATERIAL_ORIGINAL_PRICE_COUNT_SQL = "select count(1) from DSDATA.pmaauc_t " +
+            "where pmaauc001 = :czbh and pmaaucent = :ent";
+
     // 料件视图直接返回视图完整字段,可按料号或中文品名模糊筛选。
     private static final String ERP_ITEM_VIEW_DICTIONARY_CODE = "erp-item-view";
     private static final String ERP_ITEM_VIEW_DATA_SQL = "select * from DSDATA.erp_apmt440_imaauc_v " +
@@ -193,19 +201,65 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
             "and t1.pmdi004 = :mainPmdi004 and t1.pmdj002 = :detail1Ljbh";
     private static final String ERP_LAST_PRICE_COUNT_SQL = "select count(1) from (" + ERP_LAST_PRICE_DATA_SQL + ")";
 
+    // 已审核未发齐出通单按公司、据点和出通单项次精确查询,并关联料件及客户中文资料。
+    private static final String ERP_UNFULFILLED_ORDER_DICTIONARY_CODE = "erp-unfulfilled-order";
+    private static final String ERP_UNFULFILLED_ORDER_DATA_SQL =
+            "select xmdg_t.xmdgdocno, xmdh_t.xmdhseq, xmda_t.xmda048, xmdc_t.xmdcud010, xmdh_t.xmdh006, " +
+                    "imaal_t.imaal003, imaal_t.imaal004, xmdg_t.xmdg005, pmaal_t.pmaal003 " +
+                    "from DSDATA.xmdg_t " +
+                    "inner join DSDATA.xmdh_t on xmdg_t.xmdgent = xmdh_t.xmdhent " +
+                    "and xmdg_t.xmdgsite = xmdh_t.xmdhsite and xmdg_t.xmdgdocno = xmdh_t.xmdhdocno " +
+                    "inner join DSDATA.xmda_t on xmdg_t.xmdgent = xmda_t.xmdaent " +
+                    "and xmdg_t.xmdgsite = xmda_t.xmdasite and xmdg_t.xmdg004 = xmda_t.xmdadocno " +
+                    "inner join DSDATA.xmdc_t on xmda_t.xmdaent = xmdc_t.xmdcent " +
+                    "and xmda_t.xmdasite = xmdc_t.xmdcsite and xmda_t.xmdadocno = xmdc_t.xmdcdocno " +
+                    "inner join DSDATA.imaal_t on xmdh_t.xmdhent = imaal_t.imaalent " +
+                    "and xmdh_t.xmdh006 = imaal_t.imaal001 and imaal_t.imaal002 = 'zh_CN' " +
+                    "inner join DSDATA.pmaal_t on xmdg_t.xmdgent = pmaal_t.pmaalent " +
+                    "and xmdg_t.xmdg005 = pmaal_t.pmaal001 and pmaal_t.pmaal002 = 'zh_CN' " +
+                    "where xmdg_t.xmdgent = :ent and xmdg_t.xmdgsite = :site " +
+                    "and xmdg_t.xmdgdocno || '-' || to_char(xmdh_t.xmdhseq) = :keyword";
+    private static final String ERP_UNFULFILLED_ORDER_COUNT_SQL =
+            "select count(1) from (" + ERP_UNFULFILLED_ORDER_DATA_SQL + ")";
+
+    // 出通单列表按公司和据点返回指定状态的出通明细,xmdc019 转换为便于宜搭展示的中文标识。
+    private static final String ERP_DELIVERY_ORDER_DICTIONARY_CODE = "erp-delivery-order";
+    private static final String ERP_DELIVERY_ORDER_DATA_SQL =
+            "select xmdg_t.xmdgdocno || '-' || to_char(xmdh_t.xmdhseq) xmdgdocno, xmdc_t.xmdcud010, " +
+                    "case when xmdc_t.xmdc019 = '9' then '样品' else '一般' end xmdc019, " +
+                    "xmdh_t.xmdh017 - xmdh_t.xmdh030 k " +
+                    "from DSDATA.xmdg_t " +
+                    "inner join DSDATA.xmdh_t on xmdg_t.xmdgent = xmdh_t.xmdhent " +
+                    "and xmdg_t.xmdgsite = xmdh_t.xmdhsite and xmdg_t.xmdgdocno = xmdh_t.xmdhdocno " +
+                    "left join DSDATA.xmdc_t on xmdh_t.xmdhent = xmdc_t.xmdcent " +
+                    "and xmdh_t.xmdhsite = xmdc_t.xmdcsite and xmdh_t.xmdh001 = xmdc_t.xmdcdocno " +
+                    "and xmdh_t.xmdh002 = xmdc_t.xmdcseq " +
+                    "where xmdg_t.xmdgstus in ('Y', 'H', 'N') and xmdg_t.xmdgsite = :site and xmdg_t.xmdgent = :ent";
+    private static final String ERP_DELIVERY_ORDER_COUNT_SQL =
+            "select count(1) from (" + ERP_DELIVERY_ORDER_DATA_SQL + ")";
+
     // 委外采购单开窗固定查询明磊公司与 ML 据点,采购单号来自宜搭表单字段。
     private static final String ERP_OUTSOURCED_PURCHASE_ORDER_WINDOW_DICTIONARY_CODE =
             "erp-outsourced-purchase-order-window";
     private static final String OUTSOURCED_PURCHASE_ORDER_NUMBER_FILTER = "formtable_main_158_dt1_wwcgdh";
     private static final String ERP_OUTSOURCED_PURCHASE_ORDER_WINDOW_DATA_SQL =
-            "select pmdpseq, pmdp003, pmdp001, imaal003, imaal004, pmdp023 " +
+            "select pmdp_t.pmdpseq, pmdp_t.pmdp003, pmdp_t.pmdp001, imaal_t.imaal003, imaal_t.imaal004, " +
+                    "pmdp_t.pmdp023, sfaa_t.sfaaud009 " +
                     "from DSDATA.pmdp_t " +
-                    "left join DSDATA.imaal_t on pmdpent = imaalent and pmdp001 = imaal001 and imaal002 = 'zh_CN' " +
-                    "where pmdpent = 86 and pmdpsite = 'ML' and pmdpdocno = :" + OUTSOURCED_PURCHASE_ORDER_NUMBER_FILTER;
+                    "left join DSDATA.imaal_t on pmdp_t.pmdpent = imaal_t.imaalent " +
+                    "and pmdp_t.pmdp001 = imaal_t.imaal001 and imaal_t.imaal002 = 'zh_CN' " +
+                    "left join DSDATA.sfaa_t on sfaa_t.sfaaent = 86 and sfaa_t.sfaasite = 'ML' " +
+                    "and sfaa_t.sfaadocno = pmdp_t.pmdp003 " +
+                    "where pmdp_t.pmdpent = 86 and pmdp_t.pmdpsite = 'ML' and pmdp_t.pmdpdocno = :" +
+                    OUTSOURCED_PURCHASE_ORDER_NUMBER_FILTER;
     private static final String ERP_OUTSOURCED_PURCHASE_ORDER_WINDOW_COUNT_SQL =
             "select count(1) from DSDATA.pmdp_t " +
-                    "left join DSDATA.imaal_t on pmdpent = imaalent and pmdp001 = imaal001 and imaal002 = 'zh_CN' " +
-                    "where pmdpent = 86 and pmdpsite = 'ML' and pmdpdocno = :" + OUTSOURCED_PURCHASE_ORDER_NUMBER_FILTER;
+                    "left join DSDATA.imaal_t on pmdp_t.pmdpent = imaal_t.imaalent " +
+                    "and pmdp_t.pmdp001 = imaal_t.imaal001 and imaal_t.imaal002 = 'zh_CN' " +
+                    "left join DSDATA.sfaa_t on sfaa_t.sfaaent = 86 and sfaa_t.sfaasite = 'ML' " +
+                    "and sfaa_t.sfaadocno = pmdp_t.pmdp003 " +
+                    "where pmdp_t.pmdpent = 86 and pmdp_t.pmdpsite = 'ML' and pmdp_t.pmdpdocno = :" +
+                    OUTSOURCED_PURCHASE_ORDER_NUMBER_FILTER;
 
     // 委外采购对应工单备料使用采购单号与项次定位采购明细,再关联工单备料与中文品名。
     private static final String ERP_OUTSOURCED_BOM_DICTIONARY_CODE = "erp-outsourced-bom";
@@ -270,6 +324,9 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         if (ERP_PACKAGING_MATERIAL_DICTIONARY_CODE.equals(dictionaryCode)) {
             return queryErpPackagingMaterial(safeFilters);
         }
+        if (ERP_MATERIAL_ORIGINAL_PRICE_DICTIONARY_CODE.equals(dictionaryCode)) {
+            return queryErpMaterialOriginalPrice(safeFilters);
+        }
         if (ERP_ITEM_VIEW_DICTIONARY_CODE.equals(dictionaryCode)) {
             return queryErpItemView(safeFilters);
         }
@@ -294,6 +351,12 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         if (ERP_LAST_PRICE_DICTIONARY_CODE.equals(dictionaryCode)) {
             return queryErpLastPrice(safeFilters);
         }
+        if (ERP_UNFULFILLED_ORDER_DICTIONARY_CODE.equals(dictionaryCode)) {
+            return queryErpUnfulfilledOrder(safeFilters);
+        }
+        if (ERP_DELIVERY_ORDER_DICTIONARY_CODE.equals(dictionaryCode)) {
+            return queryErpDeliveryOrder(safeFilters);
+        }
         if (ERP_OUTSOURCED_PURCHASE_ORDER_WINDOW_DICTIONARY_CODE.equals(dictionaryCode)) {
             return queryErpOutsourcedPurchaseOrderWindow(safeFilters);
         }
@@ -370,6 +433,20 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         return queryPage(dataSql, countSql, parameters, createPageRequest());
     }
 
+    /**
+     * 根据材料编号查询指定公司的原单价及材料基础信息。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 原单价查询分页记录
+     */
+    private Map<String, Object> queryErpMaterialOriginalPrice(Map<String, String> filters) {
+        validateFilters(filters, ERP_MATERIAL_ORIGINAL_PRICE_DICTIONARY_CODE, "czbh");
+        MapSqlParameterSource parameters = createCompanyParameters(filters);
+        parameters.addValue("czbh", requireTextFilter(filters, "czbh"));
+        return queryPage(ERP_MATERIAL_ORIGINAL_PRICE_DATA_SQL, ERP_MATERIAL_ORIGINAL_PRICE_COUNT_SQL,
+                parameters, createPageRequest());
+    }
+
     /**
      * 查询 ERP 料件视图的全部字段,并可按料号或中文品名模糊筛选。
      *
@@ -392,16 +469,24 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
     }
 
     /**
-     * 查询指定据点的销售订单开窗数据,仅返回已审核且存在未交数量的订单明细。
+     * 查询指定据点的销售订单开窗数据,仅返回已审核且存在未交数量的订单明细,并可按订单号模糊筛选。
      *
      * @param filters 调用方传入的筛选条件
      * @return 销售订单开窗分页记录
      */
     private Map<String, Object> queryErpSalesOrder(Map<String, String> filters) {
-        validateFilters(filters, ERP_SALES_ORDER_DICTIONARY_CODE);
+        validateFilters(filters, ERP_SALES_ORDER_DICTIONARY_CODE, "keyword");
         MapSqlParameterSource parameters = new MapSqlParameterSource();
         parameters.addValue("t100id", requireTextFilter(filters, "t100id"));
-        return queryPage(ERP_SALES_ORDER_DATA_SQL, ERP_SALES_ORDER_COUNT_SQL, parameters, createPageRequest());
+        String dataSql = ERP_SALES_ORDER_DATA_SQL;
+        String countSql = ERP_SALES_ORDER_COUNT_SQL;
+        String keyword = optionalTextFilter(filters, "keyword");
+        if (keyword != null) {
+            dataSql += " and xmdadocno like :keyword";
+            countSql = "select count(1) from (" + dataSql + ")";
+            parameters.addValue("keyword", createLikeValue(keyword));
+        }
+        return queryPage(dataSql, countSql, parameters, createPageRequest());
     }
 
     /**
@@ -516,6 +601,42 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         return queryPage(ERP_LAST_PRICE_DATA_SQL, ERP_LAST_PRICE_COUNT_SQL, parameters, createPageRequest());
     }
 
+    /**
+     * 查询指定公司和据点中指定出通单项次的未发齐明细。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 未发齐出通单分页记录
+     */
+    private Map<String, Object> queryErpUnfulfilledOrder(Map<String, String> filters) {
+        validateFilters(filters, ERP_UNFULFILLED_ORDER_DICTIONARY_CODE, "site", "keyword");
+        MapSqlParameterSource parameters = createCompanyParameters(filters);
+        parameters.addValue("site", requireTextFilter(filters, "site"));
+        parameters.addValue("keyword", requireTextFilter(filters, "keyword"));
+        return queryPage(ERP_UNFULFILLED_ORDER_DATA_SQL, ERP_UNFULFILLED_ORDER_COUNT_SQL,
+                parameters, createPageRequest());
+    }
+
+    /**
+     * 查询指定公司和据点中状态为已审核、处理中或未结案的出通单明细,并可按出通单号模糊筛选。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 出通单列表分页记录
+     */
+    private Map<String, Object> queryErpDeliveryOrder(Map<String, String> filters) {
+        validateFilters(filters, ERP_DELIVERY_ORDER_DICTIONARY_CODE, "site", "keyword");
+        MapSqlParameterSource parameters = createCompanyParameters(filters);
+        parameters.addValue("site", requireTextFilter(filters, "site"));
+        String dataSql = ERP_DELIVERY_ORDER_DATA_SQL;
+        String countSql = ERP_DELIVERY_ORDER_COUNT_SQL;
+        String keyword = optionalTextFilter(filters, "keyword");
+        if (keyword != null) {
+            dataSql += " and (xmdg_t.xmdgdocno || '-' || to_char(xmdh_t.xmdhseq)) like :keyword";
+            countSql = "select count(1) from (" + dataSql + ")";
+            parameters.addValue("keyword", createLikeValue(keyword));
+        }
+        return queryPage(dataSql, countSql, parameters, createPageRequest());
+    }
+
     /**
      * 查询指定委外采购单的明细料件信息。
      *

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

@@ -14,6 +14,11 @@ public class EmployeeRosterRecord {
     private String jobGradeId;
     private String department;
     private String firstDepartment;
+    private String hireDate;
+    private String positionName;
+    private String bankCardNo;
+    private String bankName;
+    private String bankBranchCode;
     /**
      * 数据库人员状态:1 表示在职,0 表示离职。
      */
@@ -91,6 +96,46 @@ public class EmployeeRosterRecord {
         this.firstDepartment = firstDepartment;
     }
 
+    public String getHireDate() {
+        return hireDate;
+    }
+
+    public void setHireDate(String hireDate) {
+        this.hireDate = hireDate;
+    }
+
+    public String getPositionName() {
+        return positionName;
+    }
+
+    public void setPositionName(String positionName) {
+        this.positionName = positionName;
+    }
+
+    public String getBankCardNo() {
+        return bankCardNo;
+    }
+
+    public void setBankCardNo(String bankCardNo) {
+        this.bankCardNo = bankCardNo;
+    }
+
+    public String getBankName() {
+        return bankName;
+    }
+
+    public void setBankName(String bankName) {
+        this.bankName = bankName;
+    }
+
+    public String getBankBranchCode() {
+        return bankBranchCode;
+    }
+
+    public void setBankBranchCode(String bankBranchCode) {
+        this.bankBranchCode = bankBranchCode;
+    }
+
     public int getIsResigned() {
         return isResigned;
     }

+ 82 - 3
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepository.java

@@ -10,6 +10,8 @@ import org.springframework.stereotype.Repository;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -20,6 +22,9 @@ import java.util.Map;
 @Repository
 public class EmployeeRosterRepository {
 
+    private static final DateTimeFormatter EMPLOYEE_NO_DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd");
+    private static final int MAX_DAILY_EMPLOYEE_SEQUENCE = 9999;
+
     private final ObjectProvider<JdbcTemplate> jdbcTemplateProvider;
     private final EmployeeRosterProperties properties;
 
@@ -54,6 +59,26 @@ public class EmployeeRosterRepository {
         return records.size();
     }
 
+    /**
+     * 从员工花名册中锁定当日工号范围并分配下一位工号。工号记录与创建账号操作处于同一事务,
+     * 避免依赖额外流水表,并确保并发请求不会读取到相同的当日最大工号。
+     *
+     * @param date 工号所属日期
+     * @return 格式为 YYMMDD 加四位流水号的员工工号
+     */
+    public String reserveNextEmployeeNo(LocalDate date) {
+        if (date == null) {
+            throw new IllegalArgumentException("date must not be null");
+        }
+        JdbcTemplate jdbcTemplate = requireJdbcTemplate();
+        String datePrefix = EMPLOYEE_NO_DATE_FORMAT.format(date);
+        Integer sequence = resolveNextDailySequence(jdbcTemplate, datePrefix);
+        if (sequence.intValue() < 1 || sequence.intValue() > MAX_DAILY_EMPLOYEE_SEQUENCE) {
+            throw new IllegalStateException("Daily employee number sequence is exhausted for " + datePrefix);
+        }
+        return datePrefix + String.format("%04d", sequence);
+    }
+
     /**
      * 根据钉钉用户 ID 查询员工花名册。
      *
@@ -74,15 +99,54 @@ public class EmployeeRosterRepository {
         return findOne("employee_no", employeeNo);
     }
 
+    /**
+     * 根据员工姓名查询在职员工的工号。
+     *
+     * <p>供应商价格审批仅需工号写入 T100,避免依赖花名册中与该场景无关的扩展列。
+     *
+     * @param username 员工姓名
+     * @return 员工花名册信息,不存在时返回 null
+     */
+    public EmployeeRosterInfo findByUsername(String username) {
+        JdbcTemplate jdbcTemplate = requireJdbcTemplate();
+        String sql = "SELECT employee_no FROM " + resolveTableName()
+                + " WHERE username = ? AND is_resigned = 1 LIMIT 1";
+        List<EmployeeRosterInfo> rows = jdbcTemplate.query(sql, preparedStatement -> preparedStatement.setString(1, username),
+                (resultSet, rowNum) -> {
+                    EmployeeRosterInfo info = new EmployeeRosterInfo();
+                    info.setEmployeeNo(resultSet.getString("employee_no"));
+                    return info;
+                });
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
     private EmployeeRosterInfo findOne(String columnName, String value) {
         JdbcTemplate jdbcTemplate = requireJdbcTemplate();
-        String sql = "SELECT id, dingtalk_id, employee_no, username, job_title, job_title_id, job_grade, job_grade_id, department, first_department " +
+        String sql = "SELECT id, dingtalk_id, employee_no, username, job_title, job_title_id, job_grade, job_grade_id, department, first_department, hire_date, position_name, bank_card_no, bank_name, bank_branch_code " +
                 "FROM " + resolveTableName() + " WHERE " + columnName + " = ? AND is_resigned = 1 LIMIT 1";
         List<EmployeeRosterInfo> rows = jdbcTemplate.query(sql, preparedStatement -> preparedStatement.setString(1, value),
                 (resultSet, rowNum) -> mapRow(resultSet));
         return rows.isEmpty() ? null : rows.get(0);
     }
 
+    private Integer resolveNextDailySequence(JdbcTemplate jdbcTemplate, String datePrefix) {
+        String maximumEmployeeNo = jdbcTemplate.query(
+                "SELECT employee_no FROM " + resolveTableName() +
+                        " WHERE employee_no >= ? AND employee_no < ? AND employee_no REGEXP ?" +
+                        " ORDER BY employee_no DESC LIMIT 1 FOR UPDATE",
+                preparedStatement -> {
+                    preparedStatement.setString(1, datePrefix);
+                    preparedStatement.setString(2, datePrefix + ":");
+                    preparedStatement.setString(3, "^" + datePrefix + "[0-9]{4}$");
+                },
+                resultSet -> resultSet.next() ? resultSet.getString("employee_no") : null);
+        if (maximumEmployeeNo == null) {
+            return 1;
+        }
+        int lastSequence = Integer.parseInt(maximumEmployeeNo.substring(datePrefix.length()));
+        return lastSequence + 1;
+    }
+
     /**
      * 查询所有在职重名人员及其人数,用于同步后通知人事核对。
      *
@@ -114,6 +178,11 @@ public class EmployeeRosterRepository {
         info.setJobGradeId(resultSet.getString("job_grade_id"));
         info.setDepartment(resultSet.getString("department"));
         info.setFirstDepartment(resultSet.getString("first_department"));
+        info.setHireDate(resultSet.getString("hire_date"));
+        info.setPositionName(resultSet.getString("position_name"));
+        info.setBankCardNo(resultSet.getString("bank_card_no"));
+        info.setBankName(resultSet.getString("bank_name"));
+        info.setBankBranchCode(resultSet.getString("bank_branch_code"));
         return info;
     }
 
@@ -128,6 +197,11 @@ public class EmployeeRosterRepository {
         statement.setString(index++, record.getJobGradeId());
         statement.setString(index++, record.getDepartment());
         statement.setString(index++, record.getFirstDepartment());
+        statement.setString(index++, record.getHireDate());
+        statement.setString(index++, record.getPositionName());
+        statement.setString(index++, record.getBankCardNo());
+        statement.setString(index++, record.getBankName());
+        statement.setString(index++, record.getBankBranchCode());
         statement.setInt(index, record.getIsResigned());
     }
 
@@ -141,8 +215,8 @@ public class EmployeeRosterRepository {
 
     private String createMergeSql(String tableName) {
         return "INSERT INTO " + tableName + " " +
-                "(dingtalk_id, employee_no, username, job_title, job_title_id, job_grade, job_grade_id, department, first_department, is_resigned) " +
-                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
+                "(dingtalk_id, employee_no, username, job_title, job_title_id, job_grade, job_grade_id, department, first_department, hire_date, position_name, bank_card_no, bank_name, bank_branch_code, is_resigned) " +
+                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
                 "ON DUPLICATE KEY UPDATE " +
                 "dingtalk_id = VALUES(dingtalk_id), " +
                 "employee_no = VALUES(employee_no), " +
@@ -153,6 +227,11 @@ public class EmployeeRosterRepository {
                 "job_grade_id = VALUES(job_grade_id), " +
                 "department = VALUES(department), " +
                 "first_department = VALUES(first_department), " +
+                "hire_date = VALUES(hire_date), " +
+                "position_name = VALUES(position_name), " +
+                "bank_card_no = VALUES(bank_card_no), " +
+                "bank_name = VALUES(bank_name), " +
+                "bank_branch_code = VALUES(bank_branch_code), " +
                 "is_resigned = VALUES(is_resigned)";
     }
 

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

@@ -1,6 +1,9 @@
 package com.malk.minglei.service.impl.employee;
 
+import com.alibaba.fastjson.JSON;
 import com.malk.minglei.config.EmployeeRosterProperties;
+import com.malk.minglei.dto.DingTalkDedicatedAccountCreateRequest;
+import com.malk.minglei.dto.DingTalkDedicatedAccountCreateResult;
 import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.EmployeeRosterSyncResult;
 import com.malk.minglei.service.employee.DingTalkEmployeeRosterClient;
@@ -9,6 +12,7 @@ import com.malk.server.dingtalk.DDConf;
 import com.malk.service.dingtalk.DDClient;
 import com.malk.service.dingtalk.DDClient_Contacts;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -17,6 +21,8 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.time.LocalDate;
+import java.time.ZoneId;
 
 /**
  * 员工花名册同步与查询服务实现。
@@ -29,6 +35,8 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
     private static final long ROOT_DEPARTMENT_ID = 1L;
     private static final int DEFAULT_USER_PAGE_SIZE = 100;
     private static final int DEFAULT_ROSTER_BATCH_SIZE = 20;
+    private static final ZoneId EMPLOYEE_NO_ZONE_ID = ZoneId.of("Asia/Shanghai");
+    private static final String DEDICATED_ACCOUNT_CREDENTIAL_PREFIX = "ML_";
 
     private final DDClient dingTalkClient;
     private final DDClient_Contacts contactsClient;
@@ -64,6 +72,104 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         this.duplicateNameNotifier = duplicateNameNotifier;
     }
 
+    /**
+     * 调用钉钉通讯录创建自建企业账号。钉钉 OA 部门选择字段允许包含对象或嵌套 value,
+     * 此处只提取部门 ID 并去重,避免将展示名称传入钉钉接口。
+     *
+     * @param request 账号创建请求
+     * @return 钉钉创建结果
+     */
+    @Override
+    @Transactional(transactionManager = "employeeRosterTransactionManager")
+    public DingTalkDedicatedAccountCreateResult createDingTalkDedicatedAccount(DingTalkDedicatedAccountCreateRequest request) {
+        if (request == null) {
+            throw new IllegalArgumentException("request must not be null");
+        }
+
+        String accessToken = dingTalkClient.getAccessToken();
+        require(accessToken, "Unable to obtain DingTalk access token");
+
+        List<Long> departmentIds = resolveDepartmentIds(accessToken, request.getDepartmentList());
+        if (departmentIds.isEmpty()) {
+            throw new IllegalArgumentException("departmentList must contain at least one DingTalk department name or ID");
+        }
+
+        String employeeNo = optionalText(request.getEmployeeNo());
+        if (employeeNo == null) {
+            employeeNo = repository.reserveNextEmployeeNo(LocalDate.now(EMPLOYEE_NO_ZONE_ID));
+        }
+        String accountCredential = DEDICATED_ACCOUNT_CREDENTIAL_PREFIX + employeeNo;
+
+        Map<String, Object> extraFields = new LinkedHashMap<String, Object>();
+        extraFields.put("job_number", employeeNo);
+        putIfNotBlank(extraFields, "title", request.getJobTitle());
+        String gender = normalizeGender(request.getGender());
+        if (gender != null) {
+            extraFields.put("gender", gender);
+        }
+        if (request.getExpectedArrivalDate() != null) {
+            extraFields.put("hired_date", Long.valueOf(request.getExpectedArrivalDate()
+                    .atStartOfDay(EMPLOYEE_NO_ZONE_ID).toInstant().toEpochMilli()));
+        }
+        Map<String, Object> extension = new LinkedHashMap<String, Object>();
+        putIfNotBlank(extension, "positionName", request.getPositionName());
+        if (request.getAge() != null) {
+            extension.put("age", request.getAge());
+        }
+        putIfNotBlank(extension, "jobGrade", request.getJobGrade());
+        putIfNotBlank(extension, "idCardNo", request.getIdCardNo());
+        if (!extension.isEmpty()) {
+            extraFields.put("extension", JSON.toJSONString(extension));
+        }
+        Map response = contactsClient.createUser_dingTalk(
+                accessToken,
+                accountCredential,
+                accountCredential,
+                requiredText(request.getName(), "name must not be blank"),
+                departmentIds,
+                extraFields);
+        if (response == null) {
+            throw new IllegalStateException("DingTalk dedicated account creation response is empty");
+        }
+        String userId = extractUserId(response);
+        require(userId, "DingTalk dedicated account creation response does not contain userid");
+
+        EmployeeRosterRecord record = new EmployeeRosterRecord();
+        record.setDingtalkId(userId);
+        record.setEmployeeNo(employeeNo);
+        record.setUsername(requiredText(request.getName(), "name must not be blank"));
+        record.setPositionName(optionalText(request.getPositionName()));
+        record.setHireDate(request.getExpectedArrivalDate() == null ? null : request.getExpectedArrivalDate().toString());
+        record.setJobTitle(optionalText(request.getJobTitle()));
+        record.setJobGrade(optionalText(request.getJobGrade()));
+        record.setIsResigned(1);
+        if (repository.upsertAll(Collections.singletonList(record)) != 1) {
+            throw new IllegalStateException("Failed to persist the newly created employee roster record");
+        }
+        return new DingTalkDedicatedAccountCreateResult(userId, employeeNo, departmentIds);
+    }
+
+    private String normalizeGender(String gender) {
+        String value = optionalText(gender);
+        if (value == null) {
+            return null;
+        }
+        if ("1".equals(value) || "男".equals(value) || "male".equalsIgnoreCase(value)) {
+            return "1";
+        }
+        if ("2".equals(value) || "女".equals(value) || "female".equalsIgnoreCase(value)) {
+            return "2";
+        }
+        throw new IllegalArgumentException("gender must be 1, 2, 男, 女, male or female");
+    }
+
+    private void putIfNotBlank(Map<String, Object> fields, String fieldName, String value) {
+        String text = optionalText(value);
+        if (text != null) {
+            fields.put(fieldName, text);
+        }
+    }
+
     /**
      * 从钉钉同步指定部门范围内的员工花名册,并写入本地表。
      *
@@ -157,6 +263,20 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         return repository.findByEmployeeNo(employeeNo.trim());
     }
 
+    /**
+     * 根据员工姓名查询本地员工花名册记录。
+     *
+     * @param username 员工姓名
+     * @return 员工花名册信息,不存在时返回 null
+     */
+    @Override
+    public EmployeeRosterInfo findByUsername(String username) {
+        if (username == null || username.trim().isEmpty()) {
+            throw new IllegalArgumentException("username must not be blank");
+        }
+        return repository.findByUsername(username.trim());
+    }
+
     private List<Map> listUsers(String accessToken, long departmentId, int type) {
         Set<Long> departmentIds = new LinkedHashSet<Long>();
         departmentIds.add(departmentId);
@@ -176,6 +296,192 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         return new ArrayList<Map>(usersByUserId.values());
     }
 
+    /**
+     * 将 OA 传入的部门名称或部门 ID 转换为钉钉创建用户接口所需的部门 ID。
+     *
+     * <p>部门名称不能直接传给钉钉创建用户接口。名称按层级路径逐段查询直属子部门,
+     * 避免为一次账号创建遍历整个部门树;直接传入部门 ID 的历史调用方式保持兼容。</p>
+     *
+     * @param accessToken 钉钉应用访问令牌
+     * @param departmentList OA 部门选择结果,支持名称、ID 或包含名称/ID 的对象
+     * @return 去重后的钉钉部门 ID 列表
+     */
+    private List<Long> resolveDepartmentIds(String accessToken, List<Object> departmentList) {
+        Set<Long> departmentIds = new LinkedHashSet<Long>();
+        Set<String> departmentNames = new LinkedHashSet<String>();
+        if (departmentList != null) {
+            for (Object department : departmentList) {
+                collectDepartmentReferences(department, departmentIds, departmentNames);
+            }
+        }
+
+        if (departmentNames.isEmpty()) {
+            return new ArrayList<Long>(departmentIds);
+        }
+
+        for (String departmentName : departmentNames) {
+            departmentIds.add(resolveDepartmentIdByPath(accessToken, departmentName));
+        }
+        return new ArrayList<Long>(departmentIds);
+    }
+
+    /**
+     * 从根部门开始按路径段查询直属子部门,得到目标部门 ID。
+     *
+     * @param accessToken 钉钉应用访问令牌
+     * @param departmentPath 以 - 或 / 分隔的部门路径
+     * @return 路径末端的钉钉部门 ID
+     */
+    private Long resolveDepartmentIdByPath(String accessToken, String departmentPath) {
+        long parentDepartmentId = ROOT_DEPARTMENT_ID;
+        for (String departmentName : splitDepartmentPath(departmentPath)) {
+            Set<Long> matchedDepartmentIds = new LinkedHashSet<Long>();
+            List<Map> childDepartments = contactsClient.listSubDepartmentDetail(accessToken, parentDepartmentId);
+            if (childDepartments != null) {
+                for (Map childDepartment : childDepartments) {
+                    String childDepartmentName = firstText(childDepartment, "name", "dept_name", "deptName");
+                    if (!departmentName.equals(normalizeDepartmentName(childDepartmentName))) {
+                        continue;
+                    }
+                    Long childDepartmentId = toPositiveLong(firstNotNull(childDepartment.get("dept_id"),
+                            childDepartment.get("deptId"), childDepartment.get("id")));
+                    if (childDepartmentId != null) {
+                        matchedDepartmentIds.add(childDepartmentId);
+                    }
+                }
+            }
+            if (matchedDepartmentIds.isEmpty()) {
+                throw new IllegalArgumentException("departmentList contains an unknown DingTalk department path: " + departmentPath);
+            }
+            if (matchedDepartmentIds.size() > 1) {
+                throw new IllegalArgumentException("departmentList matches multiple DingTalk departments for name: " + departmentName);
+            }
+            parentDepartmentId = matchedDepartmentIds.iterator().next().longValue();
+        }
+        return Long.valueOf(parentDepartmentId);
+    }
+
+    private List<String> splitDepartmentPath(String departmentPath) {
+        List<String> departmentNames = new ArrayList<String>();
+        for (String segment : departmentPath.split("[-/]")) {
+            String departmentName = normalizeDepartmentName(segment);
+            if (departmentName == null) {
+                throw new IllegalArgumentException("departmentList contains an invalid DingTalk department path: " + departmentPath);
+            }
+            departmentNames.add(departmentName);
+        }
+        return departmentNames;
+    }
+
+    private void collectDepartmentReferences(Object value, Set<Long> departmentIds, Set<String> departmentNames) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                collectDepartmentReferences(item, departmentIds, departmentNames);
+            }
+            return;
+        }
+        if (value instanceof Map) {
+            Map department = (Map) value;
+            if (collectDepartmentIdsFromObject(department, departmentIds)) {
+                return;
+            }
+            String departmentName = firstText(department, "name", "deptName", "dept_name", "departmentName",
+                    "department_name", "label", "text");
+            if (departmentName != null) {
+                departmentNames.add(departmentName);
+                return;
+            }
+            collectDepartmentReferences(department.get("value"), departmentIds, departmentNames);
+            collectDepartmentReferences(department.get("departments"), departmentIds, departmentNames);
+            collectDepartmentReferences(department.get("departmentList"), departmentIds, departmentNames);
+            collectDepartmentReferences(department.get("deptIdList"), departmentIds, departmentNames);
+            return;
+        }
+        if (value instanceof Number) {
+            addDepartmentId(((Number) value).longValue(), departmentIds);
+            return;
+        }
+        if (value instanceof String) {
+            String text = ((String) value).trim();
+            if (text.isEmpty()) {
+                return;
+            }
+            for (String departmentReference : text.split(",")) {
+                String reference = optionalText(departmentReference);
+                if (reference == null) {
+                    continue;
+                }
+                if (isNumericDepartmentId(reference)) {
+                    addDepartmentId(Long.parseLong(reference), departmentIds);
+                } else {
+                    departmentNames.add(reference);
+                }
+            }
+        }
+    }
+
+    private boolean collectDepartmentIdsFromObject(Object value, Set<Long> departmentIds) {
+        int departmentIdCount = departmentIds.size();
+        if (value instanceof Map) {
+            Map department = (Map) value;
+            collectDepartmentIdsFromObject(department.get("id"), departmentIds);
+            collectDepartmentIdsFromObject(department.get("deptId"), departmentIds);
+            collectDepartmentIdsFromObject(department.get("dept_id"), departmentIds);
+            collectDepartmentIdsFromObject(department.get("departmentId"), departmentIds);
+            collectDepartmentIdsFromObject(department.get("department_id"), departmentIds);
+            collectDepartmentIdsFromObject(department.get("value"), departmentIds);
+            return departmentIds.size() > departmentIdCount;
+        }
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                collectDepartmentIdsFromObject(item, departmentIds);
+            }
+            return departmentIds.size() > departmentIdCount;
+        }
+        if (value instanceof Number) {
+            addDepartmentId(((Number) value).longValue(), departmentIds);
+        } else if (value instanceof String) {
+            String text = optionalText((String) value);
+            if (text != null) {
+                for (String departmentId : text.split(",")) {
+                    String candidate = optionalText(departmentId);
+                    if (candidate != null && isNumericDepartmentId(candidate)) {
+                        addDepartmentId(Long.parseLong(candidate), departmentIds);
+                    }
+                }
+            }
+        }
+        return departmentIds.size() > departmentIdCount;
+    }
+
+    private String normalizeDepartmentName(String departmentName) {
+        return optionalText(departmentName);
+    }
+
+    private boolean isNumericDepartmentId(String value) {
+        return value.matches("-?[0-9]+");
+    }
+
+    private Long toPositiveLong(Object value) {
+        if (value instanceof Number) {
+            long number = ((Number) value).longValue();
+            return number > 0 ? Long.valueOf(number) : null;
+        }
+        String text = toStringValue(value);
+        if (text == null || !isNumericDepartmentId(text)) {
+            return null;
+        }
+        long number = Long.parseLong(text);
+        return number > 0 ? Long.valueOf(number) : null;
+    }
+
+    private void addDepartmentId(long departmentId, Set<Long> departmentIds) {
+        if (departmentId < 1) {
+            throw new IllegalArgumentException("departmentList contains an invalid DingTalk department ID");
+        }
+        departmentIds.add(Long.valueOf(departmentId));
+    }
+
     private void appendDepartmentUsers(String accessToken, long departmentId, Map<String, Map> usersByUserId) {
         long cursor = 0L;
         int pageSize = normalizePageSize(properties.getUserPageSize());
@@ -247,6 +553,11 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         record.setJobGradeId(findRosterValue(rosterValues, "职等", "ad410990-cee4-4e19-b70f-189a8ab87527", "job_grade"));
         record.setDepartment(findRosterValue(rosterValues, "部门", "所在部门", "部门名称", "department"));
         record.setFirstDepartment(findRosterValue(rosterValues, "一级部门", "一级部门名称", "first_department"));
+        record.setHireDate(findRosterValue(rosterValues, "\u5165\u804c\u65f6\u95f4", "hire_date"));
+        record.setPositionName(findRosterLabel(rosterValues, "\u804c\u4f4d", "sys00-position", "position_name"));
+        record.setBankCardNo(findRosterValue(rosterValues, "\u94f6\u884c\u5361\u53f7", "bank_card_no"));
+        record.setBankName(findRosterValue(rosterValues, "\u5f00\u6237\u884c", "bank_name"));
+        record.setBankBranchCode(findRosterValue(rosterValues, "\u652f\u884c\u8054\u884c\u53f7", "bank_branch_code"));
         record.setIsResigned(isActive(user) ? 1 : 0);
         return record;
     }
@@ -572,6 +883,20 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         }
     }
 
+    private String requiredText(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalArgumentException(message);
+        }
+        return value.trim();
+    }
+
+    private String optionalText(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        return value.trim();
+    }
+
     private static class RosterFieldValue {
 
         private String label;
@@ -597,4 +922,5 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
             return label == null && value == null;
         }
     }
+
 }

+ 91 - 20
src/main/java/com/malk/minglei/service/impl/port/MingleiPortServiceImpl.java

@@ -1,5 +1,7 @@
 package com.malk.minglei.service.impl.port;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.malk.minglei.config.MingleiPortProperties;
 import com.malk.minglei.dto.MingleiPortRequest;
 import com.malk.minglei.service.port.MingleiPortService;
@@ -14,15 +16,19 @@ import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestClientResponseException;
 
+import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
 /**
- * 使用 HTTP 客户端调用供应商价格审批单(核价)外部接口。
+ * 使用 HTTP 客户端调用明磊外部 T100 单据接口。
  */
 @Service
 public class MingleiPortServiceImpl implements MingleiPortService {
@@ -35,18 +41,23 @@ public class MingleiPortServiceImpl implements MingleiPortService {
     private static final String HOST_ACCOUNT = "tiptop";
     private static final String SERVICE_PRODUCT = "T100";
     private static final String SERVICE_NAME = "ProcurementApprovalPrice";
+    private static final String T100_DOCUMENT_SERVICE_NAME = "DingTalk To T100";
     private static final String SERVICE_ID = "toptst";
     private static final String ENTERPRISE_ID = "87";
     private static final String COMPANY_ID = "ML";
+    private static final ZoneId T100_ZONE = ZoneId.of("Asia/Shanghai");
     private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
 
     private final RestTemplate restTemplate;
     private final MingleiPortProperties properties;
+    private final ObjectMapper objectMapper;
 
     public MingleiPortServiceImpl(@Qualifier("mingleiPortRestTemplate") RestTemplate restTemplate,
-                                  MingleiPortProperties properties) {
+                                  MingleiPortProperties properties,
+                                  ObjectMapper objectMapper) {
         this.restTemplate = restTemplate;
         this.properties = properties;
+        this.objectMapper = objectMapper;
     }
 
     /**
@@ -57,50 +68,96 @@ public class MingleiPortServiceImpl implements MingleiPortService {
      */
     @Override
     public Map<String, Object> submitSupplierPriceApproval(MingleiPortRequest request) {
+        if (request == null) {
+            throw new IllegalArgumentException("request must not be null");
+        }
+        LOGGER.info("Submitting supplier price approval documentNo={} detailCount={}",
+                new Object[]{request.getDocumentNumber(), request.getDetailCount()});
+
+        Map<String, Object> parameter = new LinkedHashMap<String, Object>();
+        parameter.put("head", request.getHead());
+        parameter.put("detail", request.getDetail());
+        return submit(parameter, null, SERVICE_NAME);
+    }
+
+    /**
+     * 使用已有的 T100 协议外层转发非核价单据,调用方只负责组装 parameter 节点。
+     *
+     * @param parameter T100 payload.std_data.parameter 节点
+     * @param dataKeyType T100 datakey.type 业务类型
+     * @return 外部接口原始响应内容
+     */
+    @Override
+    public Map<String, Object> submitT100Document(Map<String, Object> parameter, String dataKeyType) {
+        if (parameter == null || parameter.isEmpty()) {
+            throw new IllegalArgumentException("parameter must not be empty");
+        }
+        if (dataKeyType == null || dataKeyType.trim().isEmpty()) {
+            throw new IllegalArgumentException("dataKeyType must not be blank");
+        }
+        LOGGER.info("Submitting T100 document dataKeyType={}", dataKeyType);
+        return submit(parameter, dataKeyType.trim(), T100_DOCUMENT_SERVICE_NAME);
+    }
+
+    private Map<String, Object> submit(Map<String, Object> parameter, String dataKeyType, String serviceName) {
         HttpHeaders headers = new HttpHeaders();
-        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
         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>>() {
-                });
+        ResponseEntity<Map<String, Object>> response;
+        try {
+            Map<String, Object> externalRequest = createExternalRequest(parameter, dataKeyType, serviceName);
+            LOGGER.info("T100 push request endpoint={} requestBody={}",
+                    properties.getSupplierPriceApprovalUrl(), toLogJson(externalRequest));
+            response = restTemplate.exchange(
+                    properties.getSupplierPriceApprovalUrl(),
+                    HttpMethod.POST,
+                    new HttpEntity<Map<String, Object>>(externalRequest, headers),
+                    new ParameterizedTypeReference<Map<String, Object>>() {
+                    });
+        } catch (RestClientResponseException exception) {
+            LOGGER.error("T100 document request failed stage=T100_DOCUMENT_PUSH status={} responseBodyLength={}",
+                    exception.getRawStatusCode(), exception.getResponseBodyAsByteArray().length);
+            throw exception;
+        } catch (RestClientException exception) {
+            LOGGER.error("T100 document request failed stage=T100_DOCUMENT_PUSH exceptionType={}",
+                    exception.getClass().getSimpleName());
+            throw exception;
+        }
         return response.getBody() == null ? Collections.<String, Object>emptyMap() : response.getBody();
     }
 
     /**
-     * 使用固定的外层协议字段组装外部接口请求,仅透传调用方提供的 head 和 detail。
+     * 使用固定的外层协议字段组装外部接口请求,并透传调用方提供的 parameter 节点。
      *
-     * @param request 本地精简请求体
+     * @param parameter T100 payload.std_data.parameter 节点
+     * @param dataKeyType T100 datakey.type 业务类型;核价单为空
      * @return 外部接口完整请求体
      */
-    private Map<String, Object> createExternalRequest(MingleiPortRequest request) {
+    private Map<String, Object> createExternalRequest(Map<String, Object> parameter, String dataKeyType,
+                                                       String serviceName) {
         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", LocalDateTime.now().format(TIMESTAMP_FORMATTER));
+        // T100 会校验时间戳,不能依赖部署服务器的 JVM 默认时区。
+        host.put("timestamp", LocalDateTime.now(T100_ZONE).format(TIMESTAMP_FORMATTER));
 
         Map<String, Object> service = new LinkedHashMap<String, Object>();
         service.put("prod", SERVICE_PRODUCT);
-        service.put("name", SERVICE_NAME);
+        service.put("name", serviceName);
         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());
+        if (dataKeyType != null) {
+            dataKey.put("type", dataKeyType);
+        }
 
         Map<String, Object> standardData = new LinkedHashMap<String, Object>();
         standardData.put("parameter", parameter);
@@ -118,6 +175,20 @@ public class MingleiPortServiceImpl implements MingleiPortService {
         return externalRequest;
     }
 
+    /**
+     * 输出 T100 实际报文以便排查字段映射,同时避免将接口密钥写入日志。
+     */
+    private String toLogJson(Map<String, Object> externalRequest) {
+        Map<String, Object> logRequest = new LinkedHashMap<String, Object>(externalRequest);
+        logRequest.put("key", "******");
+        try {
+            return objectMapper.writeValueAsString(logRequest);
+        } catch (JsonProcessingException exception) {
+            LOGGER.warn("Failed to serialize T100 push request for logging", exception);
+            return logRequest.toString();
+        }
+    }
+
     /**
      * 校验调用外部接口需要的地址和密钥配置是否完整。
      */

+ 24 - 2
src/main/java/com/malk/minglei/service/impl/port/ReplyErpServiceImpl.java

@@ -1,5 +1,7 @@
 package com.malk.minglei.service.impl.port;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.malk.minglei.config.ReplyErpProperties;
 import com.malk.minglei.dto.ReplyErpRequest;
 import com.malk.minglei.service.port.ReplyErpService;
@@ -42,11 +44,14 @@ public class ReplyErpServiceImpl implements ReplyErpService {
 
     private final RestTemplate restTemplate;
     private final ReplyErpProperties properties;
+    private final ObjectMapper objectMapper;
 
     public ReplyErpServiceImpl(@Qualifier("replyErpRestTemplate") RestTemplate restTemplate,
-                               ReplyErpProperties properties) {
+                               ReplyErpProperties properties,
+                               ObjectMapper objectMapper) {
         this.restTemplate = restTemplate;
         this.properties = properties;
+        this.objectMapper = objectMapper;
     }
 
     /**
@@ -65,10 +70,13 @@ public class ReplyErpServiceImpl implements ReplyErpService {
         headers.setContentType(MediaType.APPLICATION_JSON);
         headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
 
+        Map<String, Object> externalRequest = createExternalRequest(request);
+        LOGGER.info("T100 reply request endpoint={} requestBody={}",
+                properties.getUrl(), toLogJson(externalRequest));
         ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
                 properties.getUrl(),
                 HttpMethod.POST,
-                new HttpEntity<Map<String, Object>>(createExternalRequest(request), headers),
+                new HttpEntity<Map<String, Object>>(externalRequest, headers),
                 new ParameterizedTypeReference<Map<String, Object>>() {
                 });
         return response.getBody() == null ? Collections.<String, Object>emptyMap() : response.getBody();
@@ -118,6 +126,20 @@ public class ReplyErpServiceImpl implements ReplyErpService {
         return externalRequest;
     }
 
+    /**
+     * 输出 T100 实际报文以便排查回写数据,同时避免将接口密钥写入日志。
+     */
+    private String toLogJson(Map<String, Object> externalRequest) {
+        Map<String, Object> logRequest = new LinkedHashMap<String, Object>(externalRequest);
+        logRequest.put("key", "******");
+        try {
+            return objectMapper.writeValueAsString(logRequest);
+        } catch (JsonProcessingException exception) {
+            LOGGER.warn("Failed to serialize T100 reply request for logging", exception);
+            return logRequest.toString();
+        }
+    }
+
     private void validateConfiguration() {
         require(properties.getUrl(), "MINGLEI_REPLY_ERP_URL is not configured");
         require(properties.getKey(), "MINGLEI_REPLY_ERP_KEY is not configured");

+ 552 - 56
src/main/java/com/malk/minglei/service/impl/port/SupplierPriceApprovalProcessServiceImpl.java

@@ -2,13 +2,19 @@ package com.malk.minglei.service.impl.port;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.MingleiPortRequest;
-import com.malk.minglei.service.approval.DingTalkApprovalClient;
+import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.minglei.service.port.MingleiPortService;
 import com.malk.minglei.service.port.SupplierPriceApprovalProcessService;
-import com.malk.service.dingtalk.DDClient;
+import com.malk.minglei.service.yida.YidaFormInstanceClient;
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.web.client.RestClientException;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
@@ -25,22 +31,31 @@ import java.util.List;
 import java.util.Map;
 
 /**
- * 将指定宜搭供应商价格审批流程实例转换为 T100 核价单请求。
+ * 将指定宜搭流程实例转换为对应的 T100 单据请求。
  *
- * <p>字段 ID 和 T100 字段的对应关系来自《供应商价格审批单(核价)字段清单》;
- * 该类仅负责读取、转换和转发,不修改宜搭流程数据。
+ * <p>该类读取宜搭表单实例数据、转换并推送 T100;四张供应商价格审批单推送成功后回写 ERP 单号。
  */
 @Service
 public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApprovalProcessService {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(SupplierPriceApprovalProcessServiceImpl.class);
+    //供应商价格审批单(核价)
     private static final String SUPPLIER_PRICE_APPROVAL_FORM_ID = "FORM-468B6FB2D5BF490A8BC6DBD6FAF9BCCA28R9";
+    //供应商价格审批单(暂估)
     private static final String ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID = "FORM-F906C997A4A44DC8B9180198C0B2E55F6W0S";
+    //供应商价格审批单(子公司核价)
     private static final String SUBSIDIARY_SUPPLIER_PRICE_APPROVAL_FORM_ID = "FORM-C09818E73B1640A9AA9F6D7F55C47612EFV8";
+    //供应商价格审批单(子公司暂估)
     private static final String SUBSIDIARY_ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID = "FORM-EC46C7109182497796AA0B70F3848576UYBU";
+    private static final String PACKAGING_MATERIAL_PRICE_APPROVAL_FORM_ID = "FORM-69A12600F87349D3B653BF86E419224DLIX9";
+    private static final String SUPPLIER_SUSPEND_EXIT_FORM_ID = "FORM-0A05D30C6E0745EC9F4B74D0E038EE73O7B6";
+    private static final String PACKAGING_MATERIAL_ITEM_LIST_FORM_ID = "FORM-F88BDA6B3A114E668AD5A6347AE4E01C91O9";
+    private static final String PACKAGING_MATERIAL_PRICE_APPROVAL_TYPE = "priceSheets";
+    private static final String SUPPLIER_SUSPEND_EXIT_TYPE = "suspensionExit";
+    private static final String PACKAGING_MATERIAL_ITEM_LIST_TYPE = "partNumberListAudit";
     private static final Map<String, SupplierPriceApprovalFormMapping> FORM_MAPPINGS = createFormMappings();
+    private static final Map<String, String> T100_DOCUMENT_NUMBER_WRITE_BACK_FIELDS = createT100DocumentNumberWriteBackFields();
 
-    private static final String DEFAULT_ESTIMATED_FLAG = "N";
     private static final String DEFAULT_DOCUMENT_STATUS = "I";
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final List<DateTimeFormatter> DATE_FORMATTERS = Arrays.asList(
@@ -48,9 +63,11 @@ public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApp
             DateTimeFormatter.ofPattern("yyyy/M/d"),
             DateTimeFormatter.ofPattern("yyyy/MM/dd"));
 
-    private final DDClient dingTalkClient;
-    private final DingTalkApprovalClient approvalClient;
+    private final YidaFormInstanceClient yidaFormInstanceClient;
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
     private final MingleiPortService mingleiPortService;
+    private final EmployeeRosterService employeeRosterService;
     private final ObjectMapper objectMapper;
 
     /**
@@ -60,48 +77,69 @@ public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApp
      */
     private static Map<String, SupplierPriceApprovalFormMapping> createFormMappings() {
         Map<String, SupplierPriceApprovalFormMapping> mappings = new LinkedHashMap<String, SupplierPriceApprovalFormMapping>();
+        //供应商价格审批单(核价)
         mappings.put(SUPPLIER_PRICE_APPROVAL_FORM_ID, new SupplierPriceApprovalFormMapping(
-                "employeeField_msh186rr", "selectField_mszqctpc", "serialNumberField_msmwc0yx",
-                "dateField_mszqctpf", "associationFormField_mu9juhgz", "dateField_mszqctpx",
-                "dateField_mszqctpy", "selectField_mszqctq2", "textField_mszqctqb",
-                "tableField_mszqctqd", "associationFormField_mu9juhh0", "numberField_mszqctqh",
+                "employeeField_msh186rr", "selectField_mszqctpc", "textField_mvhhjsy8",
+                "dateField_mszqctpf", "textField_mvius864", "dateField_mszqctpx",
+                "dateField_mszqctpy", "selectField_mszqctq2", "serialNumberField_msmwc0yx",
+                "tableField_mszqctqd", "selectField_mvhrhghk", "numberField_mszqctqh",
                 "textField_mszqctql"));
+        //供应商价格审批单(暂估)
         mappings.put(ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID, new SupplierPriceApprovalFormMapping(
-                "employeeField_msh186rr", "selectField_mt7yf5x2", "serialNumberField_msmwc0yx",
-                "dateField_mt7yf5w6", "selectField_mvanfd8c", "dateField_mt7yf5wm",
-                "dateField_mt7yf5wn", "selectField_mt7yf5wo", "textField_mt7yf5wu",
-                "tableField_msh186sv", "selectField_mvanfd8d", "numberField_mti2sdb4",
+                "employeeField_msh186rr", "departmentSelectField_msh186ry", "textField_mti3631t",
+                "dateField_mt7yf5w6", "textField_mvius864", "dateField_mt7yf5wm",
+                "dateField_mt7yf5wn", "selectField_mt7yf5wo", "serialNumberField_msmwc0yx",
+                "tableField_msh186sv", "selectField_mvanfd8d", "numberField_mt6viiqh",
                 "textField_mt6ur4w8"));
+        //供应商价格审批单(子公司核价)
         mappings.put(SUBSIDIARY_SUPPLIER_PRICE_APPROVAL_FORM_ID, new SupplierPriceApprovalFormMapping(
-                "employeeField_msh186rr", "selectField_mszqctpc", "serialNumberField_msmwc0yx",
-                "dateField_mszqctpf", "selectField_mvejv4mx", "dateField_mszqctpx",
-                "dateField_mszqctpy", "selectField_mszqctq2", "textField_mszqctqb",
+                "employeeField_msh186rr", "selectField_mszqctpc", "textField_mvhhkiyu",
+                "dateField_mszqctpf", "textField_mvius864", "dateField_mszqctpx",
+                "dateField_mszqctpy", "selectField_mszqctq2", "serialNumberField_msmwc0yx",
                 "tableField_mszqctqd", "selectField_mvejv4mz", "numberField_mszqctqh",
                 "textField_mszqctql"));
+        //供应商价格审批单(子公司暂估)
         mappings.put(SUBSIDIARY_ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID, new SupplierPriceApprovalFormMapping(
-                "employeeField_msh186rr", "selectField_mt7yf5x2", "serialNumberField_msmwc0yx",
-                "dateField_mt7yf5w6", "selectField_mvf0cizj", "dateField_mt7yf5wm",
-                "dateField_mt7yf5wn", "selectField_mt7yf5wo", "textField_mt7yf5wu",
+                "employeeField_msh186rr", "selectField_mt7yf5x2", "textField_mvhhbywg",
+                "dateField_mt7yf5w6", "textField_mvius864", "dateField_mt7yf5wm",
+                "dateField_mt7yf5wn", "selectField_mt7yf5wo", "serialNumberField_msmwc0yx",
                 "tableField_msh186sv", "selectField_mvf0cizk", "numberField_mt6viiqh",
                 "textField_mt6ur4w8"));
         return Collections.unmodifiableMap(mappings);
     }
 
     /**
-     * 创建流程实例转 T100 核价单服务。
+     * 注册各供应商价格审批单的 T100 单号回写字段。
+     */
+    private static Map<String, String> createT100DocumentNumberWriteBackFields() {
+        Map<String, String> fields = new LinkedHashMap<String, String>();
+        fields.put(SUPPLIER_PRICE_APPROVAL_FORM_ID, "textField_mszqctqb");
+        fields.put(ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID, "textField_mt7yf5wu");
+        fields.put(SUBSIDIARY_SUPPLIER_PRICE_APPROVAL_FORM_ID, "textField_mszqctqb");
+        fields.put(SUBSIDIARY_ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID, "textField_mt7yf5wu");
+        return Collections.unmodifiableMap(fields);
+    }
+//
+    /**
+     * 创建宜搭流程实例转 T100 单据服务。
      *
-     * @param dingTalkClient 钉钉客户端,用于获取访问令牌
-     * @param approvalClient 审批实例查询客户端
-     * @param mingleiPortService T100 核价单推送服务
+     * @param yidaFormInstanceClient 宜搭表单实例查询客户端
+     * @param mingleiPortService T100 单据推送服务
+     * @param employeeRosterService 员工花名册查询服务
      * @param objectMapper JSON 解析器
      */
-    public SupplierPriceApprovalProcessServiceImpl(DDClient dingTalkClient,
-                                                   DingTalkApprovalClient approvalClient,
+    public SupplierPriceApprovalProcessServiceImpl(
+                                                   YidaFormInstanceClient yidaFormInstanceClient,
+                                                   YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                                   YidaApplicationsProperties applications,
                                                    MingleiPortService mingleiPortService,
+                                                   EmployeeRosterService employeeRosterService,
                                                    ObjectMapper objectMapper) {
-        this.dingTalkClient = dingTalkClient;
-        this.approvalClient = approvalClient;
+        this.yidaFormInstanceClient = yidaFormInstanceClient;
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.coordinationOfficeConfiguration = applications.getCoordinationOffice();
         this.mingleiPortService = mingleiPortService;
+        this.employeeRosterService = employeeRosterService;
         this.objectMapper = objectMapper;
     }
 
@@ -109,42 +147,398 @@ public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApp
      * 查询流程详情,按已确认字段映射组装 T100 请求后推送。
      *
      * @param formId 宜搭表单 ID,用于选择字段映射规则
-     * @param processInstanceId 宜搭流程实例 ID
+     * @param formInstanceId 宜搭表单实例 ID,用于读取表单数据
+     * @param processInstanceId 宜搭流程实例 ID,四张供应商价格审批单回写时必填
      * @return T100 接口原始响应内容
      */
     @Override
-    public Map<String, Object> submitByProcessInstanceId(String formId, String processInstanceId) {
+    public Map<String, Object> submitByInstanceIds(String formId, String formInstanceId,
+                                                    String processInstanceId) {
         String requestedFormId = requireText(formId, "formId must not be blank");
-        SupplierPriceApprovalFormMapping mapping = resolveFormMapping(requestedFormId);
-        String instanceId = requireText(processInstanceId, "processInstanceId must not be blank");
-        String accessToken = requireText(dingTalkClient.getAccessToken(), "Unable to obtain DingTalk access token");
-        Map<String, Object> instanceDetail = approvalClient.getProcessInstanceDetail(accessToken, instanceId);
-        Map<?, ?> result = requireMap(instanceDetail.get("result"), "Approval instance " + instanceId + " has no form data");
-        validateFormUuid(result, requestedFormId, instanceId);
+        SupplierPriceApprovalFormMapping mapping = FORM_MAPPINGS.get(requestedFormId);
+        String dataKeyType = resolveDataKeyType(requestedFormId);
+        if (mapping == null && dataKeyType == null) {
+            throw new IllegalArgumentException("Unsupported supplier price approval formId: " + requestedFormId);
+        }
+        String requestedProcessInstanceId = mapping == null ? null : requireText(processInstanceId,
+                "processInstanceId must not be blank for supplier price approval forms");
+        String requestedFormInstanceId = requireText(formInstanceId, "formInstanceId must not be blank");
+        Map<?, ?> instanceDetail;
+        try {
+            instanceDetail = requireMap(yidaFormInstanceClient.getFormInstance(requestedFormId, requestedFormInstanceId),
+                    "Yida form instance " + requestedFormInstanceId + " has no form data");
+        } catch (RestClientException exception) {
+            logExternalFailure("YIDA_FORM_INSTANCE_QUERY", requestedFormId, requestedFormInstanceId, exception);
+            throw exception;
+        }
+        Map<?, ?> result = resultOf(instanceDetail);
+        validateFormUuid(result, requestedFormId, requestedFormInstanceId);
 
-        List<?> components = asList(result.get("formComponentValues"));
+        List<?> components = toFormComponents(result);
         if (components.isEmpty()) {
-            throw new IllegalArgumentException("Approval instance " + instanceId + " has no form component values");
+            throw new IllegalArgumentException("Yida form instance " + requestedFormInstanceId + " has no form data; "
+                    + describeYidaResult(instanceDetail, result));
+        }
+
+        if (mapping != null) {
+            MingleiPortRequest request = toT100Request(requestedFormId, components, mapping);
+            LOGGER.info("Submitting supplier price approval from formId={} formInstanceId={} processInstanceId={} documentNo={} detailCount={}",
+                    requestedFormId, requestedFormInstanceId, processInstanceId,
+                    request.getDocumentNumber(), request.getDetailCount());
+            try {
+                Map<String, Object> t100Response = mingleiPortService.submitSupplierPriceApproval(request);
+                writeBackT100DocumentNumber(requestedFormId, requestedFormInstanceId,
+                        requestedProcessInstanceId, t100Response);
+                return t100Response;
+            } catch (RestClientException exception) {
+                logExternalFailure("T100_DOCUMENT_PUSH", requestedFormId, requestedFormInstanceId, exception);
+                throw exception;
+            }
+        }
+
+        Map<String, Object> parameter = toAdditionalT100Parameter(requestedFormId, components);
+        LOGGER.info("Submitting T100 document from formId={} formInstanceId={} processInstanceId={} dataKeyType={}",
+                requestedFormId, requestedFormInstanceId, processInstanceId, dataKeyType);
+        try {
+            return mingleiPortService.submitT100Document(parameter, dataKeyType);
+        } catch (RestClientException exception) {
+            logExternalFailure("T100_DOCUMENT_PUSH", requestedFormId, requestedFormInstanceId, exception);
+            throw exception;
+        }
+    }
+
+    /**
+     * T100 成功生成单号后,将单号写回发起推送的宜搭流程实例。
+     * 回写只提交目标字段,避免覆盖流程中原有的其他表单数据。
+     */
+    private void writeBackT100DocumentNumber(String formId, String formInstanceId, String processInstanceId,
+                                             Map<String, Object> t100Response) {
+        if (!isT100Success(t100Response)) {
+            LOGGER.info("Skipping Yida document number write-back because T100 was not successful formId={} processInstanceId={}",
+                    formId, processInstanceId);
+            return;
+        }
+
+        String documentNumber = requireText(extractT100DocumentNumber(t100Response),
+                "T100 successful response does not contain content.payload.std_data.parameter.pmdidocno");
+        String fieldId = T100_DOCUMENT_NUMBER_WRITE_BACK_FIELDS.get(formId);
+        if (fieldId == null) {
+            return;
+        }
+
+        Map<String, Object> updateFormData = new LinkedHashMap<String, Object>();
+        updateFormData.put(fieldId, documentNumber);
+        Map<String, Object> updateBody = new LinkedHashMap<String, Object>();
+        updateBody.put("appType", requireText(coordinationOfficeConfiguration.getAppType(),
+                "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured"));
+        updateBody.put("systemToken", requireText(coordinationOfficeConfiguration.getSystemToken(),
+                "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured"));
+        updateBody.put("formUuid", formId);
+        updateBody.put("formInstanceId", formInstanceId);
+        updateBody.put("processInstanceId", processInstanceId);
+        updateBody.put("updateFormDataJson", toJson(updateFormData, "Yida process update data"));
+
+        LOGGER.info("Writing back T100 document number to Yida formId={} processInstanceId={} fieldId={}",
+                formId, processInstanceId, fieldId);
+        DDR_New updateResponse = yidaProcessOpenApiClient.updateProcessInstance(updateBody);
+        if (updateResponse == null || !updateResponse.isSuccess()) {
+            String code = updateResponse == null ? "EMPTY_RESPONSE" : defaultText(updateResponse.getCode(), "UNKNOWN");
+            String message = updateResponse == null ? "Yida process update response is empty"
+                    : defaultText(updateResponse.getMessage(), "Yida process update failed");
+            LOGGER.error("Yida document number write-back was rejected formId={} processInstanceId={} fieldId={} code={} message={}",
+                    formId, processInstanceId, fieldId, code, message);
+            throw new McException("YIDA_PROCESS_INSTANCE_UPDATE_FAILED", "Yida process update failed ["
+                    + code + "]: " + message);
+        }
+        LOGGER.info("Yida document number write-back completed formId={} processInstanceId={} fieldId={}",
+                formId, processInstanceId, fieldId);
+    }
+
+    private boolean isT100Success(Map<String, Object> response) {
+        if (response == null) {
+            return false;
+        }
+        Object success = response.get("success");
+        if (success != null) {
+            return Boolean.TRUE.equals(success) || "true".equalsIgnoreCase(String.valueOf(success));
+        }
+        Map<?, ?> execution = t100Execution(response);
+        return execution != null && "0".equals(textValue(execution.get("code")));
+    }
+
+    private String extractT100DocumentNumber(Map<String, Object> response) {
+        Map<?, ?> payload = t100Payload(response);
+        Map<?, ?> standardData = payload == null ? null : asMap(payload.get("std_data"));
+        Map<?, ?> parameter = standardData == null ? null : asMap(standardData.get("parameter"));
+        return parameter == null ? null : textValue(parameter.get("pmdidocno"));
+    }
+
+    /**
+     * 兼容接口网关包装后的 content.payload 与 T100 直返的 payload 两种响应格式。
+     */
+    private Map<?, ?> t100Payload(Map<String, Object> response) {
+        Map<?, ?> content = asMap(response.get("content"));
+        if (content != null) {
+            Map<?, ?> payload = asMap(content.get("payload"));
+            if (payload != null) {
+                return payload;
+            }
+        }
+        return asMap(response.get("payload"));
+    }
+
+    private Map<?, ?> t100Execution(Map<String, Object> response) {
+        Map<?, ?> payload = t100Payload(response);
+        Map<?, ?> standardData = payload == null ? null : asMap(payload.get("std_data"));
+        return standardData == null ? null : asMap(standardData.get("execution"));
+    }
+
+    private String toJson(Map<String, Object> value, String fieldName) {
+        try {
+            return objectMapper.writeValueAsString(value);
+        } catch (JsonProcessingException exception) {
+            throw new IllegalStateException(fieldName + " serialization failed", exception);
+        }
+    }
+
+    /**
+     * 仅记录外部调用所在阶段与安全元信息,避免输出宜搭表单或 T100 单据正文。
+     */
+    private void logExternalFailure(String stage, String formId, String processInstanceId,
+                                    RestClientException exception) {
+        LOGGER.error("Supplier price approval external call failed stage={} formId={} processInstanceId={} "
+                        + "exceptionType={}",
+                stage, formId, processInstanceId, exception.getClass().getSimpleName());
+    }
+
+    private Map<?, ?> resultOf(Map<?, ?> instanceDetail) {
+        Map<?, ?> nestedResult = asMap(instanceDetail.get("result"));
+        // 部分宜搭响应会保留空 result,并将流程数据直接放在顶层。
+        return nestedResult == null || nestedResult.isEmpty() ? instanceDetail : nestedResult;
+    }
+
+    private List<?> toFormComponents(Map<?, ?> result) {
+        List<?> components = asList(result.get("formComponentValues"));
+        if (!components.isEmpty()) {
+            return components;
+        }
+        Map<?, ?> formData = findFormData(result, 4);
+        if (formData == null || formData.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Map<String, Object>> convertedComponents = new ArrayList<Map<String, Object>>();
+        for (Map.Entry<?, ?> entry : formData.entrySet()) {
+            if (!(entry.getKey() instanceof String)) {
+                continue;
+            }
+            Map<String, Object> component = new LinkedHashMap<String, Object>();
+            component.put("fieldId", entry.getKey());
+            component.put("value", entry.getValue());
+            convertedComponents.add(component);
+        }
+        return convertedComponents;
+    }
+
+    /**
+     * 从宜搭流程详情的包装节点中定位表单字段。不同宜搭版本会在 result、data 或 formData
+     * 中重复包装,且表单字段可能以 JSON 字符串返回。
+     *
+     * @param value 当前待解析节点
+     * @param remainingDepth 允许继续展开的包装层级
+     * @return 表单字段 Map;未找到时返回 null
+     */
+    private Map<?, ?> findFormData(Object value, int remainingDepth) {
+        Object parsedValue = parseStructuredValue(value, "Yida process form data");
+        Map<?, ?> map = asMap(parsedValue);
+        if (map == null || map.isEmpty()) {
+            return null;
+        }
+        if (containsYidaFieldId(map)) {
+            return map;
+        }
+        if (remainingDepth <= 0) {
+            return null;
+        }
+        for (String wrapperKey : Arrays.asList("formData", "data", "result", "content")) {
+            Object nestedValue = map.get(wrapperKey);
+            if (nestedValue == null) {
+                continue;
+            }
+            Map<?, ?> nestedFormData = findFormData(nestedValue, remainingDepth - 1);
+            if (nestedFormData != null) {
+                return nestedFormData;
+            }
+        }
+        return null;
+    }
+
+    private boolean containsYidaFieldId(Map<?, ?> values) {
+        for (Object key : values.keySet()) {
+            if (!(key instanceof String)) {
+                continue;
+            }
+            String fieldId = (String) key;
+            if (fieldId.startsWith("textField_") || fieldId.startsWith("numberField_")
+                    || fieldId.startsWith("selectField_") || fieldId.startsWith("dateField_")
+                    || fieldId.startsWith("tableField_") || fieldId.startsWith("associationFormField_")
+                    || fieldId.startsWith("employeeField_") || fieldId.startsWith("serialNumberField_")
+                    || fieldId.startsWith("departmentSelectField_")) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String describeYidaResult(Map<?, ?> instanceDetail, Map<?, ?> result) {
+        Object data = result.get("data");
+        Object nestedResult = instanceDetail.get("result");
+        Map<?, ?> nestedResultMap = asMap(nestedResult);
+        return "responseKeys=" + instanceDetail.keySet()
+                + ", resultType=" + (nestedResult == null ? "null" : nestedResult.getClass().getSimpleName())
+                + ", resultKeys=" + (nestedResultMap == null ? "[]" : nestedResultMap.keySet())
+                + ", dataType=" + (data == null ? "null" : data.getClass().getSimpleName());
+    }
+
+    /**
+     * 将三类非供应商价格审批表单转换为各自定义的 T100 parameter 结构。
+     */
+    private Map<String, Object> toAdditionalT100Parameter(String formId, List<?> components) {
+        if (PACKAGING_MATERIAL_PRICE_APPROVAL_FORM_ID.equals(formId)) {
+            return toPackagingMaterialPriceApprovalParameter(components);
+        }
+        if (SUPPLIER_SUSPEND_EXIT_FORM_ID.equals(formId)) {
+            return toSupplierSuspendExitParameter(components);
+        }
+        if (PACKAGING_MATERIAL_ITEM_LIST_FORM_ID.equals(formId)) {
+            return toPackagingMaterialItemListParameter(components);
+        }
+        throw new IllegalArgumentException("Unsupported T100 document formId: " + formId);
+    }
+
+    private Map<String, Object> toPackagingMaterialPriceApprovalParameter(List<?> components) {
+        String enterpriseName = requiredComponentText(components, "textField_mviyetxy", "textField_mviyetxy");
+        Map<String, Object> head = new LinkedHashMap<String, Object>();
+        head.put("pmaaucent", toT100EnterpriseCode(enterpriseName));
+        head.put("detail", toPackagingMaterialPriceApprovalDetails(components));
+
+        Map<String, Object> parameter = new LinkedHashMap<String, Object>();
+        parameter.put("head", head);
+        return parameter;
+    }
+
+    //包材材质核价
+    private List<Map<String, Object>> toPackagingMaterialPriceApprovalDetails(List<?> components) {
+        Map<?, ?> tableComponent = requireComponent(components, "tableField_mt1av9od", "tableField_mt1av9od");
+        List<?> sourceRows = extractTableRows(tableComponent);
+        List<Map<String, Object>> details = new ArrayList<Map<String, Object>>();
+        for (Object sourceRow : sourceRows) {
+            Map<?, ?> row = requireRow(sourceRow, "tableField_mt1av9od");
+            Map<String, Object> detail = new LinkedHashMap<String, Object>();
+            detail.put("pmaauc015", optionalRowNumber(row, "numberField_mt1av9oh"));
+            detail.put("pmaauc001", optionalRowText(row, "selectField_mviy59mp"));
+            details.add(detail);
         }
+        return details;
+    }
 
-        MingleiPortRequest request = toT100Request(components, mapping);
-        LOGGER.info("Submitting supplier price approval from formId={} processInstanceId={} documentNo={} detailCount={}",
-                requestedFormId, instanceId, request.getDocumentNumber(), request.getDetailCount());
-        return mingleiPortService.submitSupplierPriceApproval(request);
+    /**
+     * 供应商暂停或恢复时,T100 的表头和明细使用相同的状态、供应商与企业代码;企业据点仅在明细传递。
+     */
+    private Map<String, Object> toSupplierSuspendExitParameter(List<?> components) {
+        String status = toT100SupplierStatus(
+                requiredComponentText(components, "selectField_mt82p09c", "selectField_mt82p09c"));
+        String supplierCode = requiredComponentText(components, "textField_mvgepmw0", "textField_mvgepmw0");
+        String enterpriseName = requiredComponentText(components, "textField_mvewj97z", "textField_mvewj97z");
+        String enterpriseCode = toT100EnterpriseCode(enterpriseName);
+        String enterpriseSite = requiredComponentText(components, "textField_mvewj980", "textField_mvewj980");
+
+        Map<String, Object> head = new LinkedHashMap<String, Object>();
+        head.put("pmaa083", status);
+        head.put("pmaa001", supplierCode);
+        head.put("pmaaent", enterpriseCode);
+
+        Map<String, Object> detail = new LinkedHashMap<String, Object>();
+        detail.put("pmab074", status);
+        detail.put("pmab001", supplierCode);
+        detail.put("pmabent", enterpriseCode);
+        detail.put("pmabsite", enterpriseSite);
+
+        Map<String, Object> parameter = new LinkedHashMap<String, Object>();
+        parameter.put("head", head);
+        parameter.put("detail", detail);
+        return parameter;
+    }
+
+    /**
+     * 宜搭保存企业名称,T100 的企业字段只接受对应的企业编码。
+     */
+    private String toT100EnterpriseCode(String enterpriseName) {
+        return "明磊".equals(enterpriseName) ? "87" : "60";
     }
 
-    private MingleiPortRequest toT100Request(List<?> components, SupplierPriceApprovalFormMapping mapping) {
+    /**
+     * T100 使用数字标记供应商状态,避免将宜搭选项文本直接写入状态字段。
+     */
+    private String toT100SupplierStatus(String status) {
+        if ("正常供应商".equals(status)) {
+            return "1";
+        }
+        if ("停用".equals(status)) {
+            return "0";
+        }
+        throw new IllegalArgumentException("Unsupported supplier status: " + status);
+    }
+
+    //包材料号维护清单维护审核流程
+    private Map<String, Object> toPackagingMaterialItemListParameter(List<?> components) {
+        String enterpriseSite = requiredComponentText(components, "selectField_msy2rh6q", "selectField_msy2rh6q");
+        String enterpriseCode = toT100EnterpriseCode(enterpriseSite);
+        Map<?, ?> tableComponent = requireComponent(components, "tableField_msh186sv", "tableField_msh186sv");
+        List<?> sourceRows = extractTableRows(tableComponent);
+        List<Map<String, Object>> details = new ArrayList<Map<String, Object>>();
+        for (Object sourceRow : sourceRows) {
+            Map<?, ?> row = requireRow(sourceRow, "tableField_msh186sv");
+            Map<String, Object> detail = new LinkedHashMap<String, Object>();
+            detail.put("pmabuc001", optionalRowText(row, "textField_mvix39fn"));
+            detail.put("pmabuc002", optionalRowText(row, "textField_mvix39fm"));
+            detail.put("pmabuc024", optionalRowNumber(row, "numberField_mvjb17mf"));
+            detail.put("pmabuc005", optionalRowText(row, "selectField_mv6etsnu"));
+            detail.put("pmabuc023", optionalRowText(row, "selectField_msy2rh6z"));
+            detail.put("pmabuc025", optionalRowNumber(row, "numberField_msy2rh7a"));
+            detail.put("pmabuc026", optionalRowText(row, "textField_msy2rh7c"));
+            detail.put("pmabuc027", optionalRowNumber(row, "numberField_msy2rh73"));
+            detail.put("pmabuc028", optionalRowNumber(row, "numberField_msy2rh74"));
+            detail.put("pmabuc029", optionalRowText(row, "textField_msy2rh76"));
+            detail.put("pmabuc030", optionalRowText(row, "textField_msy2rh77"));
+            detail.put("pmabuc031", optionalRowText(row, "textField_msy2rh78"));
+            detail.put("pmabuc032", optionalRowNumber(row, "numberField_msy2rh75"));
+            detail.put("pmabucent", enterpriseCode);
+            detail.put("pmabucsite", "ML");
+            detail.put("pmabucstus", "Y");
+            details.add(detail);
+        }
+
+        Map<String, Object> head = new LinkedHashMap<String, Object>();
+        head.put("detail", details);
+        Map<String, Object> parameter = new LinkedHashMap<String, Object>();
+        parameter.put("head", head);
+        return parameter;
+    }
+
+    private MingleiPortRequest toT100Request(String formId, List<?> components,
+                                              SupplierPriceApprovalFormMapping mapping) {
         Map<String, Object> head = new LinkedHashMap<String, Object>();
         head.put("pmdidocno", requiredComponentText(components, mapping.documentNoFieldId, "单据编号"));
-        head.put("pmdi001", requiredComponentText(components, mapping.outsourceFieldId, "委外否"));
+        head.put("pmdi001", toT100YesNo(
+                requiredComponentText(components, mapping.outsourceFieldId, "委外否"), "委外否"));
         head.put("pmdi034", requiredComponentText(components, mapping.integrationDocumentNoFieldId, "单号"));
         head.put("pmdidocdt", requiredDateComponentText(components, mapping.quotationDateFieldId, "报价单日期"));
         head.put("pmdi004", requiredComponentText(components, mapping.supplierFieldId, "核价供应商"));
         head.put("pmdi015", requiredDateComponentText(components, mapping.effectiveDateFieldId, "生效日期"));
         head.put("pmdi016", requiredDateComponentText(components, mapping.expiryDateFieldId, "失效日期"));
         head.put("pmdi003", requiredComponentText(components, mapping.departmentFieldId, "使用部门"));
-        head.put("pmdi002", requiredComponentText(components, mapping.applicantFieldId, "申请人"));
-        head.put("pmdiud001", DEFAULT_ESTIMATED_FLAG);
+        head.put("pmdi002", resolveApplicantEmployeeNo(components, mapping.applicantFieldId));
+        head.put("pmdiud001", isEstimatedSupplierPriceApproval(formId) ? "Y" : "N");
         head.put("pmdistus", DEFAULT_DOCUMENT_STATUS);
 
         Map<String, Object> requestBody = new LinkedHashMap<String, Object>();
@@ -153,17 +547,53 @@ public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApp
         return objectMapper.convertValue(requestBody, MingleiPortRequest.class);
     }
 
+    /**
+     * 宜搭成员字段返回人员数组,T100 申请人字段需要花名册中的员工工号。
+     */
+    private String resolveApplicantEmployeeNo(List<?> components, String applicantFieldId) {
+        String applicantName = requiredComponentText(components, applicantFieldId, "申请人");
+        EmployeeRosterInfo employee = employeeRosterService.findByUsername(applicantName);
+        if (employee == null) {
+            throw new IllegalArgumentException("Applicant employee not found in roster: " + applicantName);
+        }
+        return requireText(employee.getEmployeeNo(), "Applicant employee has no employee_no in roster: " + applicantName);
+    }
+
+    /**
+     * 将宜搭的是否字段转换为 T100 所需的 Y/N 标识,同时兼容历史表单已保存的 Y/N 值。
+     */
+    private String toT100YesNo(String value, String fieldName) {
+        if ("是".equals(value) || "Y".equalsIgnoreCase(value)) {
+            return "Y";
+        }
+        if ("否".equals(value) || "N".equalsIgnoreCase(value)) {
+            return "N";
+        }
+        throw new IllegalArgumentException(fieldName + " must be 是 or 否");
+    }
+
+    /**
+     * 暂估类供应商价格审批单需要向 T100 标记为暂估,核价类表单保持非暂估。
+     */
+    private boolean isEstimatedSupplierPriceApproval(String formId) {
+        return ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID.equals(formId)
+                || SUBSIDIARY_ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID.equals(formId);
+    }
+
     private List<Map<String, Object>> toT100Details(List<?> components, SupplierPriceApprovalFormMapping mapping) {
         Map<?, ?> tableComponent = requireComponent(components, mapping.detailTableFieldId, "核价明细");
         List<?> sourceRows = extractTableRows(tableComponent);
         List<Map<String, Object>> details = new ArrayList<Map<String, Object>>();
-        for (Object sourceRow : sourceRows) {
+        for (int index = 0; index < sourceRows.size(); index++) {
+            Object sourceRow = sourceRows.get(index);
             Map<?, ?> row = asMap(sourceRow);
             if (row == null) {
                 throw new IllegalArgumentException("核价明细包含无法解析的行数据");
             }
             Map<String, Object> detail = new LinkedHashMap<String, Object>();
-            detail.put("pmdj011", requiredRowText(row, mapping.detailPriceFieldId, "核价"));
+            detail.put("pmdj011", requiredRowText(row, mapping.detailPriceFieldId,
+                    "核价 fieldId=" + mapping.detailPriceFieldId + ", row=" + (index + 1)
+                            + ", availableFieldIds=" + row.keySet()));
             detail.put("pmdj002", textValue(rowFieldValue(row, mapping.detailItemFieldId)));
             detail.put("pmdj030", defaultText(textValue(rowFieldValue(row, mapping.detailRemarkFieldId)), ""));
             details.add(detail);
@@ -225,6 +655,30 @@ public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApp
         return requireText(textValue(rowFieldValue(row, fieldId)), fieldName + " must not be blank");
     }
 
+    private Map<?, ?> requireRow(Object value, String fieldName) {
+        Map<?, ?> row = asMap(value);
+        if (row == null) {
+            throw new IllegalArgumentException(fieldName + " contains an invalid row");
+        }
+        return row;
+    }
+
+    private String optionalRowText(Map<?, ?> row, String fieldId) {
+        return defaultText(textValue(rowFieldValue(row, fieldId)), "");
+    }
+
+    private Object optionalRowNumber(Map<?, ?> row, String fieldId) {
+        String value = optionalRowText(row, fieldId);
+        if (value.isEmpty()) {
+            return "";
+        }
+        try {
+            return new BigDecimal(value);
+        } catch (NumberFormatException ignored) {
+            return value;
+        }
+    }
+
     private Map<?, ?> requireComponent(List<?> components, String fieldId, String fieldName) {
         Map<?, ?> component = findComponent(components, fieldId, fieldName);
         if (component == null) {
@@ -233,6 +687,24 @@ public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApp
         return component;
     }
 
+    /**
+     * 仅在字段缺失时返回实例中的字段标识,便于定位表单版本或字段映射差异,且不暴露表单值。
+     */
+    private List<String> componentFieldIds(List<?> components) {
+        List<String> fieldIds = new ArrayList<String>();
+        for (Object item : components) {
+            Map<?, ?> component = asMap(item);
+            if (component == null) {
+                continue;
+            }
+            String fieldId = textValue(firstPresent(component, "fieldId", "id", "bizAlias"));
+            if (fieldId != null) {
+                fieldIds.add(fieldId);
+            }
+        }
+        return fieldIds;
+    }
+
     private Map<?, ?> findComponent(List<?> components, String fieldId, String fieldName) {
         for (Object item : components) {
             Map<?, ?> component = asMap(item);
@@ -267,7 +739,7 @@ public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApp
     }
 
     private String textValue(Object value) {
-        Object parsedValue = parseStructuredValue(value, "字段");
+        Object parsedValue = parseTextValue(value);
         if (parsedValue == null) {
             return null;
         }
@@ -296,6 +768,25 @@ public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApp
         return text.trim().isEmpty() ? null : text.trim();
     }
 
+    /**
+     * 宜搭普通字段可能是 JSON 字符串,也可能只是恰好以 {@code [} 或 {@code \{} 开头的文本。
+     * 前者解析为结构化值,后者保留原文,避免普通字段阻断整张单据推送。
+     */
+    private Object parseTextValue(Object value) {
+        if (!(value instanceof String)) {
+            return value;
+        }
+        String text = ((String) value).trim();
+        if (!(text.startsWith("{") || text.startsWith("["))) {
+            return value;
+        }
+        try {
+            return objectMapper.readValue(text, Object.class);
+        } catch (JsonProcessingException ignored) {
+            return value;
+        }
+    }
+
     private void validateFormUuid(Map<?, ?> result, String requestedFormId, String instanceId) {
         String formUuid = textValue(firstPresent(result, "formUuid", "modelUuid"));
         if (formUuid != null && !requestedFormId.equals(formUuid)) {
@@ -303,12 +794,17 @@ public class SupplierPriceApprovalProcessServiceImpl implements SupplierPriceApp
         }
     }
 
-    private SupplierPriceApprovalFormMapping resolveFormMapping(String formId) {
-        SupplierPriceApprovalFormMapping mapping = FORM_MAPPINGS.get(formId);
-        if (mapping == null) {
-            throw new IllegalArgumentException("Unsupported supplier price approval formId: " + formId);
+    private String resolveDataKeyType(String formId) {
+        if (PACKAGING_MATERIAL_PRICE_APPROVAL_FORM_ID.equals(formId)) {
+            return PACKAGING_MATERIAL_PRICE_APPROVAL_TYPE;
+        }
+        if (SUPPLIER_SUSPEND_EXIT_FORM_ID.equals(formId)) {
+            return SUPPLIER_SUSPEND_EXIT_TYPE;
         }
-        return mapping;
+        if (PACKAGING_MATERIAL_ITEM_LIST_FORM_ID.equals(formId)) {
+            return PACKAGING_MATERIAL_ITEM_LIST_TYPE;
+        }
+        return null;
     }
 
     private Map<?, ?> requireMap(Object value, String message) {

+ 179 - 3
src/main/java/com/malk/minglei/service/impl/yida/DingTalkYidaProcessOpenApiClient.java

@@ -3,9 +3,25 @@ package com.malk.minglei.service.impl.yida;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
 import com.malk.server.dingtalk.DDR_New;
 import com.malk.service.dingtalk.DDClient;
+import org.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.util.StringUtils;
+import org.springframework.web.client.HttpStatusCodeException;
+import org.springframework.web.client.RestTemplate;
 
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 使用钉钉 OpenAPI 发起宜搭流程。
@@ -13,18 +29,26 @@ import java.util.Map;
 @Service
 public class DingTalkYidaProcessOpenApiClient implements YidaProcessOpenApiClient {
 
+    private static final Logger LOGGER = LoggerFactory.getLogger(DingTalkYidaProcessOpenApiClient.class);
     private static final String YIDA_OPEN_API_BASE_URL = "https://api.dingtalk.com/v1.0/yida";
+    private static final String YIDA_FORM_DATA_API_BASE_URL = "https://api.dingtalk.com/v2.0/yida";
     private static final String START_PROCESS_ENDPOINT = "/processes/instances/start";
-
+    private static final String UPDATE_FORM_DATA_ENDPOINT = "/forms/instances";
+    private static final String ACCESS_TOKEN_HEADER = "x-acs-dingtalk-access-token";
+    private static final String YIDA_PUBLIC_ACCOUNT = "yida_pub_account";
     private final DDClient dingTalkClient;
+    private final RestTemplate dingTalkRestTemplate;
 
     /**
      * 创建宜搭流程 OpenAPI 客户端。
      *
-     * @param dingTalkClient 钉钉开放平台客户端
+     * @param dingTalkClient 钉钉应用客户端
+     * @param dingTalkRestTemplate 钉钉开放平台 HTTP 客户端
      */
-    public DingTalkYidaProcessOpenApiClient(DDClient dingTalkClient) {
+    public DingTalkYidaProcessOpenApiClient(DDClient dingTalkClient,
+                                            @Qualifier("dingTalkRestTemplate") RestTemplate dingTalkRestTemplate) {
         this.dingTalkClient = dingTalkClient;
+        this.dingTalkRestTemplate = dingTalkRestTemplate;
     }
 
     /**
@@ -37,4 +61,156 @@ public class DingTalkYidaProcessOpenApiClient implements YidaProcessOpenApiClien
     public DDR_New startProcess(Map<String, Object> body) {
         return DDR_New.doPost(YIDA_OPEN_API_BASE_URL + START_PROCESS_ENDPOINT, dingTalkClient.initTokenHeader(), null, body);
     }
+
+    /**
+     * 使用钉钉开放平台 UpdateFormData v2 接口回写流程表单数据。
+     *
+     * @param body 宜搭流程实例更新请求体
+     * @return 钉钉 OpenAPI 原始响应
+     */
+    @Override
+    public DDR_New updateProcessInstance(Map<String, Object> body) {
+        Map<String, Object> requestBody = toUpdateFormDataRequest(body);
+        String endpoint = YIDA_FORM_DATA_API_BASE_URL + UPDATE_FORM_DATA_ENDPOINT;
+        LOGGER.info("Yida form instance update request endpoint={} body={}", endpoint, maskSensitive(requestBody));
+        try {
+            ResponseEntity<Map<String, Object>> httpResponse = dingTalkRestTemplate.exchange(
+                    endpoint,
+                    HttpMethod.PUT,
+                    new HttpEntity<Map<String, Object>>(requestBody, createJsonHeaders()),
+                    new ParameterizedTypeReference<Map<String, Object>>() {
+                    });
+            DDR_New response = toDingTalkResponse(httpResponse.getBody(), httpResponse.getStatusCodeValue());
+            LOGGER.info("Yida form instance update response endpoint={} status={} responseKeys={} success={} code={} message={} formInstanceId={} processInstanceId={}",
+                    endpoint,
+                    httpResponse.getStatusCodeValue(), responseKeys(httpResponse.getBody()),
+                    response.isSuccess(), response.getCode(), response.getMessage(),
+                    requestBody.get("formInstanceId"), body == null ? null : body.get("processInstanceId"));
+            return response;
+        } catch (HttpStatusCodeException exception) {
+            String responseBody = sanitizeResponseBody(exception.getResponseBodyAsString(),
+                    textValue(requestBody.get("systemToken")));
+            LOGGER.error("Yida form instance update rejected endpoint={} status={} formInstanceId={} processInstanceId={} "
+                            + "responseBodyLength={} responseBody={}",
+                    endpoint, exception.getRawStatusCode(), requestBody.get("formInstanceId"),
+                    body == null ? null : body.get("processInstanceId"), exception.getResponseBodyAsByteArray().length,
+                    responseBody);
+            return failedResponse(String.valueOf(exception.getRawStatusCode()),
+                    "Yida form instance update request was rejected: " + responseBody);
+        } catch (RuntimeException exception) {
+            LOGGER.error("Yida form instance update failed endpoint={} formInstanceId={} processInstanceId={} exceptionType={} message={}",
+                    endpoint, requestBody.get("formInstanceId"), body == null ? null : body.get("processInstanceId"),
+                    exception.getClass().getSimpleName(), exception.getMessage(), exception);
+            return failedResponse("YIDA_FORM_INSTANCE_UPDATE_FAILED",
+                    "Yida form instance update request failed");
+        }
+    }
+
+    private Map<String, Object> toUpdateFormDataRequest(Map<String, Object> body) {
+        Map<String, Object> requestBody = new LinkedHashMap<String, Object>();
+        requestBody.put("appType", requiredText(body, "appType"));
+        requestBody.put("systemToken", requiredText(body, "systemToken"));
+        requestBody.put("userId", YIDA_PUBLIC_ACCOUNT);
+        requestBody.put("language", "zh_CN");
+        requestBody.put("formInstanceId", requiredText(body, "formInstanceId"));
+        requestBody.put("useLatestVersion", false);
+        requestBody.put("useAlias", false);
+        requestBody.put("formUuid", requiredText(body, "formUuid"));
+        requestBody.put("updateFormDataJson", requiredText(body, "updateFormDataJson"));
+        return requestBody;
+    }
+
+    private HttpHeaders createJsonHeaders() {
+        String accessToken = dingTalkClient.getAccessToken();
+        if (!StringUtils.hasText(accessToken)) {
+            throw new IllegalStateException("Unable to obtain DingTalk access token");
+        }
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
+        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
+        headers.set(ACCESS_TOKEN_HEADER, accessToken.trim());
+        return headers;
+    }
+
+    private DDR_New toDingTalkResponse(Map<String, Object> responseBody, int statusCode) {
+        DDR_New response = new DDR_New();
+        Map<String, Object> safeResponseBody = responseBody == null
+                ? Collections.<String, Object>emptyMap()
+                : responseBody;
+        response.setCode(textValue(firstPresent(safeResponseBody, "code", "errcode")));
+        response.setMessage(textValue(firstPresent(safeResponseBody, "message", "errmsg", "errMsg")));
+        response.setResult(safeResponseBody.get("result"));
+        response.setData(safeResponseBody.get("data"));
+        Object success = safeResponseBody.get("success");
+        if (success != null) {
+            response.setSuccess(Boolean.TRUE.equals(success) || "true".equalsIgnoreCase(String.valueOf(success)));
+        } else {
+            String code = response.getCode();
+            response.setSuccess(statusCode >= 200 && statusCode < 300
+                    && (code == null || "0".equals(code) || "200".equals(code)));
+        }
+        if (!response.isSuccess() && !StringUtils.hasText(response.getMessage())) {
+            response.setMessage("Yida form instance update did not return a successful result");
+        }
+        return response;
+    }
+
+    private Set<String> responseKeys(Map<String, Object> responseBody) {
+        return responseBody == null ? Collections.<String>emptySet() : responseBody.keySet();
+    }
+
+    private DDR_New failedResponse(String code, String message) {
+        DDR_New response = new DDR_New();
+        response.setSuccess(false);
+        response.setCode(code);
+        response.setMessage(message);
+        return response;
+    }
+
+    private Object firstPresent(Map<String, Object> source, String... fieldNames) {
+        for (String fieldName : fieldNames) {
+            Object value = source.get(fieldName);
+            if (value != null) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private String textValue(Object value) {
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private String requiredText(Map<String, Object> body, String fieldName) {
+        if (body == null || body.get(fieldName) == null || String.valueOf(body.get(fieldName)).trim().isEmpty()) {
+            throw new IllegalArgumentException("Yida process update request field " + fieldName + " must not be blank");
+        }
+        return String.valueOf(body.get(fieldName)).trim();
+    }
+
+    /**
+     * 日志保留请求结构用于排查,但不输出宜搭 System Token。
+     */
+    private Map<String, Object> maskSensitive(Map<String, Object> body) {
+        if (body == null) {
+            return null;
+        }
+        Map<String, Object> masked = new LinkedHashMap<String, Object>(body);
+        if (masked.containsKey("systemToken")) {
+            masked.put("systemToken", "******");
+        }
+        return masked;
+    }
+
+    private String sanitizeResponseBody(String responseBody, String systemToken) {
+        if (!StringUtils.hasText(responseBody)) {
+            return "<empty>";
+        }
+        String sanitized = responseBody;
+        if (StringUtils.hasText(systemToken)) {
+            sanitized = sanitized.replace(systemToken, "******");
+        }
+        sanitized = sanitized.replaceAll("(?i)(x-acs-dingtalk-access-token[:=])[^\\s,]+", "$1******");
+        return sanitized.length() <= 4096 ? sanitized : sanitized.substring(0, 4096) + "...<truncated>";
+    }
 }

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

@@ -16,4 +16,13 @@ public interface MingleiPortService {
      * @return 外部接口原始响应体
      */
     Map<String, Object> submitSupplierPriceApproval(MingleiPortRequest request);
+
+    /**
+     * 将不同业务单据的 T100 parameter 节点按统一协议外层转发。
+     *
+     * @param parameter T100 payload.std_data.parameter 节点
+     * @param dataKeyType T100 datakey.type 业务类型
+     * @return 外部接口原始响应内容
+     */
+    Map<String, Object> submitT100Document(Map<String, Object> parameter, String dataKeyType);
 }

+ 15 - 4
src/main/java/com/malk/minglei/service/port/SupplierPriceApprovalProcessService.java

@@ -3,16 +3,27 @@ package com.malk.minglei.service.port;
 import java.util.Map;
 
 /**
- * 定义宜搭供应商价格审批流程数据转换并推送 T100 的业务能力。
+ * 定义宜搭流程数据转换并推送 T100 的业务能力。
  */
 public interface SupplierPriceApprovalProcessService {
 
     /**
-     * 根据流程实例读取宜搭表单字段,转换为 T100 核价单并发送。
+     * 根据宜搭实例读取表单字段,按表单类型转换为 T100 单据并发送。
      *
      * @param formId 宜搭表单 ID,用于选择字段映射规则
-     * @param processInstanceId 宜搭流程实例 ID
+     * @param formInstanceId 宜搭表单实例 ID,用于读取表单数据
+     * @param processInstanceId 宜搭流程实例 ID;四张供应商价格审批单回写 ERP 单号时必填
      * @return T100 接口原始响应内容
      */
-    Map<String, Object> submitByProcessInstanceId(String formId, String processInstanceId);
+    Map<String, Object> submitByInstanceIds(String formId, String formInstanceId, String processInstanceId);
+
+    /**
+     * 兼容未回写 ERP 单号的历史调用方。
+     *
+     * @deprecated 供应商价格审批单必须改用 {@link #submitByInstanceIds(String, String, String)} 并传入流程实例 ID。
+     */
+    @Deprecated
+    default Map<String, Object> submitByProcessInstanceId(String formId, String formInstanceId) {
+        return submitByInstanceIds(formId, formInstanceId, null);
+    }
 }

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

@@ -16,4 +16,12 @@ public interface YidaProcessOpenApiClient {
      * @return 钉钉 OpenAPI 原始响应
      */
     DDR_New startProcess(Map<String, Object> body);
+
+    /**
+     * 按流程实例 ID 更新宜搭流程表单字段。
+     *
+     * @param body 包含表单实例 ID、流程实例 ID 与 updateFormDataJson 的更新请求体
+     * @return 钉钉 OpenAPI 原始响应
+     */
+    DDR_New updateProcessInstance(Map<String, Object> body);
 }

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

@@ -27,7 +27,10 @@ dingtalk:
 
 minglei:
   banking:
+    # 测试环境:支付、状态、流水、余额和支行查询均使用该地址;禁止改为正式回单地址。
     base-url: ${MINGLEI_BANKING_BASE_URL:https://sandbox.nexbankapi.com}
+  nexbankapi   # 正式环境:仅电子回单下载使用该地址,不会影响支付及其他查询接口。
+    receipt-base-url: ${MINGLEI_BANKING_RECEIPT_BASE_URL:https://open..com}
     payment-encryption-enabled: ${MINGLEI_BANKING_PAYMENT_ENCRYPTION_ENABLED:true}
     app-id: ${MINGLEI_BANKING_APP_ID:f0241b326afe45fb852340120cf84302}
     app-secret: ${MINGLEI_BANKING_APP_SECRET:D0cpE1DKtoV6O0X5cQOQO4quqo1wlOR6DMICXDXuV7zv}
@@ -35,6 +38,8 @@ minglei:
     default-from-account-no: ${MINGLEI_BANKING_DEFAULT_FROM_ACCOUNT_NO:123321}
     default-from-account-name: ${MINGLEI_BANKING_DEFAULT_FROM_ACCOUNT_NAME:浙江明磊锂能源科技有限公司}
     intra-bank-name: ${MINGLEI_BANKING_INTRA_BANK_NAME:交通银行}
+    receipt-file-storage-path: ${MINGLEI_BANKING_RECEIPT_FILE_STORAGE_PATH:/minglei/pay_file}
+    public-base-url: ${MINGLEI_BANKING_PUBLIC_BASE_URL:http://www.minglei.online:55580}
     # 支付国密模式使用 16 进制 SM2 公钥/私钥;请通过环境变量注入,禁止提交真实密钥。
     xencio-public-key: ${MINGLEI_BANKING_XENCIO_PUBLIC_KEY:MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEetJMdNQheTFUCY84eZIER4A6QuCLSiFxOEQn/Ywv9mBa2J8DEzohO4rKLcL37Y/kO7rDSuE1jpkZBpEdBnJwRw==}
     enterprise-private-key: ${MINGLEI_BANKING_ENTERPRISE_PRIVATE_KEY:MIGTAgEAMBMGByqGSM49AgEGCCqBHM9VAYItBHkwdwIBAQQgt7HYxYc4GUHXsOyDcvBk1/Q5SOMbZwXsjT9wmdm1/fKgCgYIKoEcz1UBgi2hRANCAAQnLMyPqsMFqYCDq/2/s9b0+2ZkiAAUfrEMDMyKZ9c8QyqULyuUAU+MehdpuEIIrKrAmvz0k5RzOVc9Kqp3ZvXG}

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

@@ -9,6 +9,11 @@ CREATE TABLE employee_roster (
   job_grade_id VARCHAR(64) DEFAULT NULL COMMENT '职等ID',
   department VARCHAR(128) DEFAULT NULL COMMENT '部门',
   first_department VARCHAR(128) DEFAULT NULL COMMENT '一级部门',
+  hire_date DATE DEFAULT NULL COMMENT '入职时间',
+  position_name VARCHAR(128) DEFAULT NULL COMMENT '职位',
+  bank_card_no VARCHAR(64) DEFAULT NULL COMMENT '银行卡号',
+  bank_name VARCHAR(128) DEFAULT NULL COMMENT '开户行',
+  bank_branch_code VARCHAR(64) DEFAULT NULL COMMENT '支行联行号',
   is_resigned TINYINT NOT NULL DEFAULT 1 COMMENT '人员状态:1在职,0离职',
   PRIMARY KEY (id),
   UNIQUE KEY uk_dingtalk_id (dingtalk_id),

+ 3 - 1
src/test/java/com/malk/minglei/config/WebCorsConfigurationTest.java

@@ -2,6 +2,7 @@ package com.malk.minglei.config;
 
 import com.malk.minglei.controller.YidaProcessController;
 import com.malk.minglei.service.yida.MingleiDocumentService;
+import com.malk.minglei.service.yida.YidaProcessInstanceClient;
 import com.malk.minglei.service.yida.YidaProcessService;
 import org.junit.jupiter.api.Test;
 import org.springframework.http.HttpHeaders;
@@ -19,7 +20,8 @@ class WebCorsConfigurationTest {
     void acceptsCrossOriginPreflightRequestsForApiEndpoints() throws Exception {
         YidaProcessController controller = new YidaProcessController(
                 mock(YidaProcessService.class),
-                mock(MingleiDocumentService.class));
+                mock(MingleiDocumentService.class),
+                mock(YidaProcessInstanceClient.class));
         MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller)
                 .addFilters(new WebCorsConfiguration().corsFilter())
                 .build();

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

@@ -3,6 +3,8 @@ 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.DingTalkDedicatedAccountCreateRequest;
+import com.malk.minglei.dto.DingTalkDedicatedAccountCreateResult;
 import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.EmployeeRosterQueryRequest;
 import com.malk.minglei.service.employee.EmployeeRosterService;
@@ -11,6 +13,8 @@ import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 
 import java.util.Map;
+import java.util.Arrays;
+import java.util.Collections;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.BDDMockito.given;
@@ -18,6 +22,32 @@ import static org.mockito.Mockito.mock;
 
 class EmployeeRosterControllerTest {
 
+    @Test
+    void createDingTalkDedicatedAccountReturnsCreatedEmployeeNo() throws Exception {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setName("张三");
+        request.setEmployeeNo("1001");
+        request.setDepartmentList(Collections.<Object>singletonList(1L));
+        DingTalkDedicatedAccountCreateResult result = new DingTalkDedicatedAccountCreateResult(
+                "ding-user-1", "1001", Arrays.asList(1L, 2L));
+        given(employeeRosterService.createDingTalkDedicatedAccount(request)).willReturn(result);
+
+        EmployeeRosterController controller = new EmployeeRosterController(employeeRosterService);
+        ResponseEntity<ApiResponse<DingTalkDedicatedAccountCreateResult>> response =
+                controller.createDingTalkDedicatedAccount(request);
+        ObjectMapper objectMapper = new ObjectMapper();
+        Map<String, Object> body = objectMapper.readValue(
+                objectMapper.writeValueAsString(response.getBody()),
+                new TypeReference<Map<String, Object>>() {
+                });
+        Map<?, ?> responseResult = (Map<?, ?>) body.get("result");
+
+        assertEquals(HttpStatus.CREATED, response.getStatusCode());
+        assertEquals(result, response.getBody().getResult());
+        assertEquals("1001", responseResult.get("employeeNo"));
+    }
+
     @Test
     void getUserRosterReturnsRosterInfoFromDatabase() throws Exception {
         EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
@@ -32,6 +62,11 @@ class EmployeeRosterControllerTest {
         info.setJobGradeId("10010");
         info.setDepartment("钉钉顾问");
         info.setFirstDepartment("钉钉顾问");
+        info.setHireDate("2024-01-15");
+        info.setPositionName("研发工程师");
+        info.setBankCardNo("6222020000000001");
+        info.setBankName("交通银行");
+        info.setBankBranchCode("301290000001");
         given(employeeRosterService.findByUserId("41603502201288023")).willReturn(info);
 
         EmployeeRosterController controller = new EmployeeRosterController(employeeRosterService);
@@ -54,5 +89,10 @@ class EmployeeRosterControllerTest {
         assertEquals("10040", result.get("jobTitleId"));
         assertEquals("10", result.get("jobGrade"));
         assertEquals("10010", result.get("jobGradeId"));
+        assertEquals("2024-01-15", result.get("hireDate"));
+        assertEquals("研发工程师", result.get("positionName"));
+        assertEquals("6222020000000001", result.get("bank_card_no"));
+        assertEquals("交通银行", result.get("bank_name"));
+        assertEquals("301290000001", result.get("bank_branch_code"));
     }
 }

+ 23 - 3
src/test/java/com/malk/minglei/controller/YidaProcessControllerTest.java

@@ -7,6 +7,7 @@ import com.malk.minglei.dto.DepartmentUserIdsResponse;
 import com.malk.minglei.dto.DepartmentUserQueryRequest;
 import com.malk.minglei.dto.YidaProcessStartRequest;
 import com.malk.minglei.service.yida.MingleiDocumentService;
+import com.malk.minglei.service.yida.YidaProcessInstanceClient;
 import com.malk.minglei.service.yida.YidaProcessService;
 import org.junit.jupiter.api.Test;
 import org.springframework.http.HttpStatus;
@@ -20,6 +21,7 @@ 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;
+import static org.mockito.Mockito.verify;
 
 /**
  * 验证宜搭流程接口的 HTTP 状态和统一响应体结构。
@@ -33,7 +35,8 @@ class YidaProcessControllerTest {
     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));
+        YidaProcessController controller = new YidaProcessController(yidaProcessService, mock(MingleiDocumentService.class),
+                mock(YidaProcessInstanceClient.class));
 
         ResponseEntity<ApiResponse<Map<String, String>>> response = controller.start(new YidaProcessStartRequest());
         ObjectMapper objectMapper = new ObjectMapper();
@@ -59,7 +62,8 @@ class YidaProcessControllerTest {
         request.setUserIds(Collections.<String>emptyList());
 
         ResponseEntity<DepartmentUserIdsResponse> response = new YidaProcessController(
-                mock(YidaProcessService.class), mingleiDocumentService).listDepartmentUserIds(request);
+                mock(YidaProcessService.class), mingleiDocumentService, mock(YidaProcessInstanceClient.class))
+                .listDepartmentUserIds(request);
 
         assertEquals(Arrays.asList("department-user-1", "department-user-2"), response.getBody().getResult());
     }
@@ -75,9 +79,25 @@ class YidaProcessControllerTest {
         request.setUserIds(Arrays.asList("specified-user-1", "department-user-1", "specified-user-1"));
 
         ResponseEntity<DepartmentUserIdsResponse> response = new YidaProcessController(
-                mock(YidaProcessService.class), mingleiDocumentService).listDepartmentUserIds(request);
+                mock(YidaProcessService.class), mingleiDocumentService, mock(YidaProcessInstanceClient.class))
+                .listDepartmentUserIds(request);
 
         assertEquals(Arrays.asList("department-user-1", "department-user-2", "specified-user-1"),
                 response.getBody().getResult());
     }
+
+    @Test
+    void getProcessInstanceReturnsYidaInstanceDataWithoutStartingErpPush() {
+        YidaProcessInstanceClient yidaProcessInstanceClient = mock(YidaProcessInstanceClient.class);
+        Map<String, Object> expected = Collections.<String, Object>singletonMap("formUuid", "FORM-001");
+        given(yidaProcessInstanceClient.getProcessInstance("process-instance-1")).willReturn(expected);
+        YidaProcessController controller = new YidaProcessController(mock(YidaProcessService.class),
+                mock(MingleiDocumentService.class), yidaProcessInstanceClient);
+
+        ResponseEntity<Map<String, Object>> response = controller.getProcessInstance("process-instance-1");
+
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals(expected, response.getBody());
+        verify(yidaProcessInstanceClient).getProcessInstance("process-instance-1");
+    }
 }

+ 22 - 0
src/test/java/com/malk/minglei/service/impl/approval/DingTalkApprovalClientImplTest.java

@@ -22,6 +22,10 @@ class DingTalkApprovalClientImplTest {
 
     private static final String EXPECTED_URL =
             "https://api.dingtalk.com/v1.0/workflow/processInstances?processInstanceId=instance-001";
+    private static final String EXPECTED_YIDA_URL =
+            "https://api.dingtalk.com/v2.0/yida/processes/instancesInfos/instance-001"
+                    + "?appType=APP_RJJL69QUIZVQSV7YC8TL&systemToken=system-token&userId=41603502201288023"
+                    + "&language=zh_CN&useAlias=false";
 
     @Test
     void getProcessInstanceDetailSendsAccessTokenHeader() {
@@ -69,4 +73,22 @@ class DingTalkApprovalClientImplTest {
         Map<String, Object> detail = client.getProcessInstanceDetail("access-token", "instance-001");
         assertEquals(0, detail.size());
     }
+
+    @Test
+    void getYidaProcessInstanceDetailUsesGetInstanceByIdV2Contract() {
+        RestTemplate restTemplate = new RestTemplate();
+        MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+        server.expect(requestTo(EXPECTED_YIDA_URL))
+                .andExpect(method(HttpMethod.GET))
+                .andExpect(header("x-acs-dingtalk-access-token", "access-token"))
+                .andRespond(withSuccess("{\"formUuid\":\"FORM-001\",\"data\":{\"textField_a\":\"value\"}}",
+                        MediaType.APPLICATION_JSON));
+
+        Map<String, Object> detail = new DingTalkApprovalClientImpl(restTemplate, new ObjectMapper())
+                .getYidaProcessInstanceDetail("access-token", "instance-001", "APP_RJJL69QUIZVQSV7YC8TL",
+                        "system-token", "41603502201288023");
+
+        server.verify();
+        assertEquals("FORM-001", detail.get("formUuid"));
+    }
 }

+ 252 - 4
src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java

@@ -1,6 +1,9 @@
 package com.malk.minglei.service.impl.employee;
 
 import com.malk.minglei.config.EmployeeRosterProperties;
+import com.alibaba.fastjson.JSON;
+import com.malk.minglei.dto.DingTalkDedicatedAccountCreateRequest;
+import com.malk.minglei.dto.DingTalkDedicatedAccountCreateResult;
 import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.EmployeeRosterSyncResult;
 import com.malk.minglei.service.employee.DingTalkEmployeeRosterClient;
@@ -15,19 +18,218 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.time.LocalDate;
+import java.time.ZoneId;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 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 EmployeeRosterServiceImplTest {
 
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void createDingTalkDedicatedAccountUsesProvidedEmployeeNoForLoginCredentials() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(contactsClient.createUser_dingTalk(eq("access-token"), eq("ML_1001"), eq("ML_1001"), eq("张三"),
+                anyList(), anyMap())).willReturn(mapOf("userid", "ding-user-1"));
+        given(repository.upsertAll(anyList())).willReturn(1);
+
+        DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setName("张三");
+        request.setEmployeeNo("1001");
+        request.setPositionName("研发工程师");
+        request.setGender("男");
+        request.setAge(Integer.valueOf(28));
+        request.setExpectedArrivalDate(LocalDate.of(2026, 11, 1));
+        request.setJobTitle("高级工程师");
+        request.setJobGrade("P6");
+        request.setIdCardNo("TEST-ID-001");
+        request.setDepartmentList(Arrays.<Object>asList(
+                mapOf("id", "1"),
+                mapOf("value", mapOf("dept_id", 2L)),
+                "1,3"));
+
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
+                dingTalkClient,
+                contactsClient,
+                new DDConf(),
+                mock(DingTalkEmployeeRosterClient.class),
+                repository,
+                new EmployeeRosterProperties(),
+                mock(EmployeeRosterDuplicateNameNotifier.class));
+
+        DingTalkDedicatedAccountCreateResult result = service.createDingTalkDedicatedAccount(request);
+
+        ArgumentCaptor<List> departmentIdsCaptor = ArgumentCaptor.forClass(List.class);
+        ArgumentCaptor<Map> extraFieldsCaptor = ArgumentCaptor.forClass(Map.class);
+        ArgumentCaptor<List> rosterRecordsCaptor = ArgumentCaptor.forClass(List.class);
+        verify(contactsClient).createUser_dingTalk(eq("access-token"), eq("ML_1001"), eq("ML_1001"), eq("张三"),
+                departmentIdsCaptor.capture(), extraFieldsCaptor.capture());
+        verify(repository, never()).reserveNextEmployeeNo(any(java.time.LocalDate.class));
+        verify(repository).upsertAll(rosterRecordsCaptor.capture());
+        assertEquals(Arrays.asList(1L, 2L, 3L), departmentIdsCaptor.getValue());
+        assertEquals("1001", extraFieldsCaptor.getValue().get("job_number"));
+        assertEquals("高级工程师", extraFieldsCaptor.getValue().get("title"));
+        assertEquals("1", extraFieldsCaptor.getValue().get("gender"));
+        assertEquals(Long.valueOf(LocalDate.of(2026, 11, 1).atStartOfDay(ZoneId.of("Asia/Shanghai"))
+                .toInstant().toEpochMilli()), extraFieldsCaptor.getValue().get("hired_date"));
+        Map<String, Object> extension = JSON.parseObject((String) extraFieldsCaptor.getValue().get("extension"));
+        assertEquals("研发工程师", extension.get("positionName"));
+        assertEquals(Integer.valueOf(28), extension.get("age"));
+        assertEquals("P6", extension.get("jobGrade"));
+        assertEquals("TEST-ID-001", extension.get("idCardNo"));
+        EmployeeRosterRecord savedRecord = (EmployeeRosterRecord) rosterRecordsCaptor.getValue().get(0);
+        assertEquals("ding-user-1", savedRecord.getDingtalkId());
+        assertEquals("1001", savedRecord.getEmployeeNo());
+        assertEquals("张三", savedRecord.getUsername());
+        assertEquals("研发工程师", savedRecord.getPositionName());
+        assertEquals("2026-11-01", savedRecord.getHireDate());
+        assertEquals("高级工程师", savedRecord.getJobTitle());
+        assertEquals("P6", savedRecord.getJobGrade());
+        assertEquals("ding-user-1", result.getUserId());
+        assertEquals("1001", result.getEmployeeNo());
+        assertEquals(Arrays.asList(1L, 2L, 3L), result.getDepartmentIds());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void createDingTalkDedicatedAccountGeneratesEmployeeNoWhenItIsNotProvided() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(repository.reserveNextEmployeeNo(any(java.time.LocalDate.class))).willReturn("2610260001");
+        given(repository.upsertAll(anyList())).willReturn(1);
+        given(contactsClient.createUser_dingTalk(eq("access-token"), eq("ML_2610260001"), eq("ML_2610260001"), eq("李四"),
+                anyList(), anyMap())).willReturn(mapOf("userid", "ding-user-2"));
+
+        DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setName("李四");
+        request.setDepartmentList(Collections.<Object>singletonList(1L));
+
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
+                dingTalkClient,
+                contactsClient,
+                new DDConf(),
+                mock(DingTalkEmployeeRosterClient.class),
+                repository,
+                new EmployeeRosterProperties(),
+                mock(EmployeeRosterDuplicateNameNotifier.class));
+
+        DingTalkDedicatedAccountCreateResult result = service.createDingTalkDedicatedAccount(request);
+
+        verify(repository).reserveNextEmployeeNo(any(java.time.LocalDate.class));
+        verify(contactsClient).createUser_dingTalk(eq("access-token"), eq("ML_2610260001"), eq("ML_2610260001"), eq("李四"),
+                anyList(), anyMap());
+        assertEquals("2610260001", result.getEmployeeNo());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void createDingTalkDedicatedAccountResolvesDepartmentNameToDepartmentId() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(contactsClient.listSubDepartmentDetail("access-token", 1L)).willReturn(
+                Collections.singletonList(department(10L, "集团运营中心", 1L)));
+        given(contactsClient.listSubDepartmentDetail("access-token", 10L)).willReturn(
+                Collections.singletonList(department(20L, "人力资源部", 10L)));
+        given(contactsClient.createUser_dingTalk(eq("access-token"), eq("ML_1002"), eq("ML_1002"), eq("王五"),
+                anyList(), anyMap())).willReturn(mapOf("userid", "ding-user-3"));
+        given(repository.upsertAll(anyList())).willReturn(1);
+
+        DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setName("王五");
+        request.setEmployeeNo("1002");
+        request.setDepartmentList(Collections.<Object>singletonList("集团运营中心-人力资源部"));
+
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
+                dingTalkClient,
+                contactsClient,
+                new DDConf(),
+                mock(DingTalkEmployeeRosterClient.class),
+                repository,
+                new EmployeeRosterProperties(),
+                mock(EmployeeRosterDuplicateNameNotifier.class));
+
+        service.createDingTalkDedicatedAccount(request);
+
+        ArgumentCaptor<List> departmentIdsCaptor = ArgumentCaptor.forClass(List.class);
+        verify(contactsClient).listSubDepartmentDetail("access-token", 1L);
+        verify(contactsClient).listSubDepartmentDetail("access-token", 10L);
+        verify(contactsClient, never()).getDepartmentDetail_all("access-token", true);
+        verify(contactsClient).createUser_dingTalk(eq("access-token"), eq("ML_1002"), eq("ML_1002"), eq("王五"),
+                departmentIdsCaptor.capture(), anyMap());
+        assertEquals(Collections.singletonList(20L), departmentIdsCaptor.getValue());
+    }
+
+    @Test
+    void createDingTalkDedicatedAccountRejectsUnknownDepartmentName() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(contactsClient.listSubDepartmentDetail("access-token", 1L)).willReturn(Collections.<Map>emptyList());
+
+        DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setDepartmentList(Collections.<Object>singletonList("不存在的部门"));
+
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
+                dingTalkClient,
+                contactsClient,
+                new DDConf(),
+                mock(DingTalkEmployeeRosterClient.class),
+                mock(EmployeeRosterRepository.class),
+                new EmployeeRosterProperties(),
+                mock(EmployeeRosterDuplicateNameNotifier.class));
+
+        IllegalArgumentException exception = org.junit.jupiter.api.Assertions.assertThrows(
+                IllegalArgumentException.class,
+                () -> service.createDingTalkDedicatedAccount(request));
+
+        assertEquals("departmentList contains an unknown DingTalk department path: 不存在的部门", exception.getMessage());
+    }
+
+    @Test
+    void createDingTalkDedicatedAccountRejectsAmbiguousDepartmentName() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(contactsClient.listSubDepartmentDetail("access-token", 1L)).willReturn(Arrays.asList(
+                department(10L, "研发部", null),
+                department(20L, "研发部", null)));
+
+        DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setDepartmentList(Collections.<Object>singletonList("研发部"));
+
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
+                dingTalkClient,
+                contactsClient,
+                new DDConf(),
+                mock(DingTalkEmployeeRosterClient.class),
+                mock(EmployeeRosterRepository.class),
+                new EmployeeRosterProperties(),
+                mock(EmployeeRosterDuplicateNameNotifier.class));
+
+        IllegalArgumentException exception = org.junit.jupiter.api.Assertions.assertThrows(
+                IllegalArgumentException.class,
+                () -> service.createDingTalkDedicatedAccount(request));
+
+        assertEquals("departmentList matches multiple DingTalk departments for name: 研发部", exception.getMessage());
+    }
+
     @Test
     void findByEmployeeNoDelegatesToRepository() {
         EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
@@ -48,6 +250,26 @@ class EmployeeRosterServiceImplTest {
         verify(repository).findByEmployeeNo("1001");
     }
 
+    @Test
+    void findByUsernameDelegatesToRepository() {
+        EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setEmployeeNo("1001");
+        given(repository.findByUsername("张三")).willReturn(employee);
+
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
+                null,
+                null,
+                null,
+                null,
+                repository,
+                new EmployeeRosterProperties(),
+                mock(EmployeeRosterDuplicateNameNotifier.class));
+
+        assertSame(employee, service.findByUsername(" 张三 "));
+        verify(repository).findByUsername("张三");
+    }
+
     @Test
     @SuppressWarnings({"rawtypes", "unchecked"})
     void syncCollectsDepartmentUsersAndUpsertsRosterRecords() {
@@ -72,9 +294,15 @@ class EmployeeRosterServiceImplTest {
                         field("sys00-jobTitle", "职称"),
                         field("sys00-jobGrade", "职等"),
                         field("sys00-department", "部门"),
-                        field("sys00-firstDepartment", "一级部门")))));
+                        field("sys00-firstDepartment", "一级部门"),
+                        field("sys00-hireDate", "入职时间"),
+                        field("sys00-position", "职位"),
+                        field("bank-card-no", "银行卡号"),
+                        field("bank-name", "开户行"),
+                        field("bank-branch-code", "支行联行号")))));
         given(rosterClient.listEmployeeRoster(eq("access-token"), anyList(), eq(123456L), eq(Arrays.asList(
-                "sys00-jobTitle", "sys00-jobGrade", "sys00-department", "sys00-firstDepartment"))))
+                "sys00-jobTitle", "sys00-jobGrade", "sys00-department", "sys00-firstDepartment",
+                "sys00-hireDate", "sys00-position", "bank-card-no", "bank-name", "bank-branch-code"))))
                 .willReturn(Arrays.asList(
                         roster("user-1", "高级工程师", "P6", "技术部", "研发中心"),
                         roster("user-2", "工程师", "P5", "技术部", "研发中心"),
@@ -97,7 +325,7 @@ class EmployeeRosterServiceImplTest {
         assertEquals(1L, result.getDepartmentId());
         assertEquals(true, result.isIncludeSubDepartments());
         assertEquals(3, result.getUserCount());
-        assertEquals(4, result.getFieldCount());
+        assertEquals(9, result.getFieldCount());
         assertEquals(3, result.getSyncedCount());
 
         ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
@@ -112,6 +340,11 @@ class EmployeeRosterServiceImplTest {
         assertEquals("10010", records.get(0).getJobGradeId());
         assertEquals("技术部", records.get(0).getDepartment());
         assertEquals("研发中心", records.get(0).getFirstDepartment());
+        assertEquals("2024-01-15", records.get(0).getHireDate());
+        assertEquals("研发工程师", records.get(0).getPositionName());
+        assertEquals("6222020000000001", records.get(0).getBankCardNo());
+        assertEquals("交通银行", records.get(0).getBankName());
+        assertEquals("301290000001", records.get(0).getBankBranchCode());
         assertEquals(1, records.get(0).getIsResigned());
         verify(duplicateNameNotifier).notifyDuplicateUsernames(duplicateUsernameCounts);
     }
@@ -196,6 +429,16 @@ class EmployeeRosterServiceImplTest {
         return map;
     }
 
+    private static Map department(Long id, String name, Long parentId) {
+        Map<String, Object> department = new LinkedHashMap<String, Object>();
+        department.put("dept_id", id);
+        department.put("name", name);
+        if (parentId != null) {
+            department.put("parent_id", parentId);
+        }
+        return department;
+    }
+
     private static Map<String, Object> field(String fieldCode, String fieldName) {
         Map<String, Object> field = new LinkedHashMap<String, Object>();
         field.put("field_code", fieldCode);
@@ -208,7 +451,12 @@ class EmployeeRosterServiceImplTest {
                 rosterField("sys00-jobTitle", jobTitle, "10040"),
                 rosterField("sys00-jobGrade", jobGrade, "10010"),
                 rosterField("sys00-department", department),
-                rosterField("sys00-firstDepartment", firstDepartment)));
+                rosterField("sys00-firstDepartment", firstDepartment),
+                rosterField("sys00-hireDate", "2024-01-15"),
+                rosterField("sys00-position", "研发工程师", "position-001"),
+                rosterField("bank-card-no", "6222020000000001"),
+                rosterField("bank-name", "交通银行"),
+                rosterField("bank-branch-code", "301290000001")));
     }
 
     private static Map<String, Object> rosterField(String fieldCode, String value) {

+ 26 - 2
src/test/java/com/malk/minglei/service/impl/port/MingleiPortServiceImplTest.java

@@ -10,6 +10,7 @@ import org.springframework.test.web.client.MockRestServiceServer;
 import org.springframework.web.client.RestTemplate;
 
 import java.util.Map;
+import java.util.LinkedHashMap;
 
 import static org.hamcrest.Matchers.matchesPattern;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -34,14 +35,14 @@ class MingleiPortServiceImplTest {
         MingleiPortProperties properties = new MingleiPortProperties();
         properties.setSupplierPriceApprovalUrl("http://external.example/wtoptst/ws/r/awsp920");
         properties.setSupplierPriceApprovalKey("key-1");
-        mingleiPortService = new MingleiPortServiceImpl(restTemplate, properties);
+        mingleiPortService = new MingleiPortServiceImpl(restTemplate, properties, objectMapper);
     }
 
     @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(header("Content-Type", MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"))
                 .andExpect(jsonPath("$.key").value("key-1"))
                 .andExpect(jsonPath("$.type").value("sync"))
                 .andExpect(jsonPath("$.host.timestamp").value(matchesPattern("\\d{17}")))
@@ -59,6 +60,29 @@ class MingleiPortServiceImplTest {
         server.verify();
     }
 
+    @Test
+    void submitT100DocumentForwardsCustomParameterAndDataKeyType() {
+        server.expect(requestTo("http://external.example/wtoptst/ws/r/awsp920"))
+                .andExpect(method(org.springframework.http.HttpMethod.POST))
+                .andExpect(jsonPath("$.service.name").value("DingTalk To T100"))
+                .andExpect(jsonPath("$.datakey.type").value("供应商暂停/退出流程"))
+                .andExpect(jsonPath("$.payload.std_data.parameter.head.pmaa001").value("SUP-001"))
+                .andExpect(jsonPath("$.payload.std_data.parameter.detail.pmab001").value("SUP-001"))
+                .andRespond(withSuccess("{\"srvcode\":\"100\"}", MediaType.APPLICATION_JSON));
+        Map<String, Object> head = new LinkedHashMap<String, Object>();
+        head.put("pmaa001", "SUP-001");
+        Map<String, Object> detail = new LinkedHashMap<String, Object>();
+        detail.put("pmab001", "SUP-001");
+        Map<String, Object> parameter = new LinkedHashMap<String, Object>();
+        parameter.put("head", head);
+        parameter.put("detail", detail);
+
+        Map<String, Object> response = mingleiPortService.submitT100Document(parameter, "供应商暂停/退出流程");
+
+        assertEquals("100", response.get("srvcode"));
+        server.verify();
+    }
+
     private MingleiPortRequest readRequest() throws Exception {
         return objectMapper.readValue(localRequestJson(), MingleiPortRequest.class);
     }

+ 1 - 1
src/test/java/com/malk/minglei/service/impl/port/ReplyErpServiceImplTest.java

@@ -34,7 +34,7 @@ class ReplyErpServiceImplTest {
         ReplyErpProperties properties = new ReplyErpProperties();
         properties.setUrl("http://external.example/wtoptst/ws/r/awsp920");
         properties.setKey("key-1");
-        replyErpService = new ReplyErpServiceImpl(restTemplate, properties);
+        replyErpService = new ReplyErpServiceImpl(restTemplate, properties, objectMapper);
     }
 
     @Test