소스 검색

接口开发10.30

ht 6 일 전
부모
커밋
1759e03e8c
23개의 변경된 파일과 1792개의 추가작업 그리고 281개의 파일을 삭제
  1. 4 4
      DATA-DICTIONARY-API.md
  2. 76 65
      README.md
  3. 1 1
      src/main/java/com/malk/minglei/config/BankingConfiguration.java
  4. 1 10
      src/main/java/com/malk/minglei/config/BankingProperties.java
  5. 3 1
      src/main/java/com/malk/minglei/controller/BankingController.java
  6. 3 3
      src/main/java/com/malk/minglei/controller/TravelApplicationPushController.java
  7. 16 2
      src/main/java/com/malk/minglei/dto/DingTalkDedicatedAccountCreateRequest.java
  8. 1 4
      src/main/java/com/malk/minglei/dto/TravelApplicationPushRequest.java
  9. 1 1
      src/main/java/com/malk/minglei/dto/banking/ElectronicBillDownloadRequest.java
  10. 8 0
      src/main/java/com/malk/minglei/dto/banking/SinglePaymentRequest.java
  11. 10 0
      src/main/java/com/malk/minglei/service/employee/EmployeeRosterService.java
  12. 197 45
      src/main/java/com/malk/minglei/service/impl/banking/BankingServiceImpl.java
  13. 44 6
      src/main/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImpl.java
  14. 262 21
      src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImpl.java
  15. 761 10
      src/main/java/com/malk/minglei/service/impl/port/TravelApplicationPushServiceImpl.java
  16. 11 11
      src/main/resources/application.yml
  17. 13 3
      src/test/java/com/malk/minglei/config/BankingConfigurationTest.java
  18. 1 0
      src/test/java/com/malk/minglei/controller/EmployeeRosterControllerTest.java
  19. 3 6
      src/test/java/com/malk/minglei/controller/TravelApplicationPushControllerTest.java
  20. 68 26
      src/test/java/com/malk/minglei/service/impl/banking/BankingServiceImplTest.java
  21. 15 7
      src/test/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImplTest.java
  22. 65 20
      src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java
  23. 228 35
      src/test/java/com/malk/minglei/service/impl/port/TravelApplicationPushServiceImplTest.java

+ 4 - 4
DATA-DICTIONARY-API.md

@@ -43,7 +43,7 @@
 | `erp-material-original-price` | 根据材料编号取原单价 | `czbh`、`ent` | 无 |
 | `erp-item-view` | ERP 料件视图 | `ent` | `keyword` |
 | `erp-sales-order` | 销售订单开窗 | `t100id` | `keyword` |
-| `erp-production-order` | 生产令开窗 | `t100id`、`qx` | 无 |
+| `erp-production-order` | 生产令开窗 | `t100id`、`userId` | 无 |
 | `erp-supplier-disabled` | ERP 供应商禁用视图 | `t100id`、`ent` | `keyword` |
 | `erp-active-item` | T100 有效料号 | 无 | `keyword` |
 | `erp-template-item` | 范本料号开窗 | 无 | `keyword` |
@@ -120,11 +120,11 @@ GET /api/dictionary/erp-sales-order?t100id=ML&keyword=SO-001
 ### 4.6 生产令开窗
 
 ```http
-GET /api/dictionary/erp-production-order?t100id=ML&qx=OWNER-A,OWNER-B
+GET /api/dictionary/erp-production-order?t100id=ML&userId=USER-001
 ```
 
-- `qx`:订单拥有者权限值,多个值以英文逗号分隔;例如 `OWNER-A,OWNER-B`。
-- 固定查询公司 `86` 的已审核订单,并按 `xmdaownid` 权限范围筛选。
+- `userId`:钉钉用户 ID,必填。服务端先从本地员工花名册查询对应的 `employee_no`。
+- 固定查询公司 `86` 的已审核订单;员工工号需同时匹配订单业务员字段 `xmda002`,并将该工号在 `ooag_t` 对应的 `ooag003` 与订单部门字段 `xmdaowndp` 进行匹配。
 - 不限制未交数量。
 
 ### 4.7 ERP 供应商禁用视图

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 76 - 65
README.md


+ 1 - 1
src/main/java/com/malk/minglei/config/BankingConfiguration.java

@@ -14,7 +14,7 @@ import java.nio.charset.StandardCharsets;
  * 配置银企直连专用 HTTP 客户端,避免影响其他外部集成的超时设置。
  */
 @Configuration
-@EnableConfigurationProperties(BankingProperties.class)
+@EnableConfigurationProperties({BankingProperties.class, BankingPaymentTrackingProperties.class})
 public class BankingConfiguration {
 
     /**

+ 1 - 10
src/main/java/com/malk/minglei/config/BankingProperties.java

@@ -9,14 +9,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
 public class BankingProperties {
 
     /** 支付、状态、流水、余额和支行查询专用测试环境地址,不用于回单下载。 */
-    private String baseUrl = "https://sandbox.nexbankapi.com";
+    private String baseUrl = "https://open.nexbankapi.com";
     /** 电子回单下载专用正式环境地址,不影响其他银企接口。 */
-    private String receiptBaseUrl = "https://open.nexbankapi.com";
     private String appId;
     private String appSecret;
     private String customerCode;
-    private String defaultFromAccountNo;
-    private String defaultFromAccountName;
     private String intraBankName;
     private boolean paymentEncryptionEnabled = true;
     private String xencioPublicKey;
@@ -28,18 +25,12 @@ public class BankingProperties {
 
     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; }
     public void setAppSecret(String appSecret) { this.appSecret = appSecret; }
     public String getCustomerCode() { return customerCode; }
     public void setCustomerCode(String customerCode) { this.customerCode = customerCode; }
-    public String getDefaultFromAccountNo() { return defaultFromAccountNo; }
-    public void setDefaultFromAccountNo(String defaultFromAccountNo) { this.defaultFromAccountNo = defaultFromAccountNo; }
-    public String getDefaultFromAccountName() { return defaultFromAccountName; }
-    public void setDefaultFromAccountName(String defaultFromAccountName) { this.defaultFromAccountName = defaultFromAccountName; }
     public String getIntraBankName() { return intraBankName; }
     public void setIntraBankName(String intraBankName) { this.intraBankName = intraBankName; }
     public boolean isPaymentEncryptionEnabled() { return paymentEncryptionEnabled; }

+ 3 - 1
src/main/java/com/malk/minglei/controller/BankingController.java

@@ -218,8 +218,10 @@ public class BankingController {
         if (fileId == null || fileId.trim().isEmpty()) {
             return null;
         }
+        // 兼容历史链接或反向代理将 %20 原样转发到应用的情况。
+        String decodedFileId = UriUtils.decode(fileId, StandardCharsets.UTF_8);
         Path directory = Paths.get(bankingProperties.getReceiptFileStoragePath()).toAbsolutePath().normalize();
-        Path filePath = directory.resolve(fileId).normalize();
+        Path filePath = directory.resolve(decodedFileId).normalize();
         return directory.equals(filePath.getParent()) ? filePath : null;
     }
 

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

@@ -14,7 +14,7 @@ import javax.validation.Valid;
 import java.util.Map;
 
 /**
- * 接收钉钉 OA 已通过的出差审批数据并发起宜搭出差申请流程。
+ * 接收钉钉 OA 审批实例 ID,并发起宜搭出差申请流程。
  */
 @RestController
 @RequestMapping("/api/minglei/ports/travel-applications")
@@ -32,9 +32,9 @@ public class TravelApplicationPushController {
     }
 
     /**
-     * 将 OA 主表、行程、飞机航程和费用预算转换为目标宜搭流程实例。
+     * 根据 OA 审批实例详情转换为目标宜搭流程实例。
      *
-     * @param request OA 连接器发来的已通过审批数据
+     * @param request OA 连接器传入的审批实例 ID
      * @return OA 审批实例与宜搭流程实例的关联结果
      */
     @PostMapping

+ 16 - 2
src/main/java/com/malk/minglei/dto/DingTalkDedicatedAccountCreateRequest.java

@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonAlias;
 import com.fasterxml.jackson.annotation.JsonProperty;
 
 import javax.validation.constraints.NotBlank;
-import javax.validation.constraints.NotEmpty;
 import javax.validation.constraints.Max;
 import javax.validation.constraints.Min;
 import javax.validation.constraints.Size;
@@ -20,9 +19,11 @@ import java.util.List;
 public class DingTalkDedicatedAccountCreateRequest {
 
     @NotBlank
+    @JsonAlias("processInstanceId")
+    private String oaProcessInstanceId;
+
     private String name;
 
-    @NotEmpty
     @JsonProperty("departmentList")
     @JsonAlias({"departments", "deptIdList", "dept_id_list"})
     private List<Object> departmentList;
@@ -186,4 +187,17 @@ public class DingTalkDedicatedAccountCreateRequest {
         this.idCardNo = idCardNo;
     }
 
+    /**
+     * 返回钉钉 OA 审批实例 ID。
+     *
+     * @return 审批实例 ID
+     */
+    public String getOaProcessInstanceId() {
+        return oaProcessInstanceId;
+    }
+
+    public void setOaProcessInstanceId(String oaProcessInstanceId) {
+        this.oaProcessInstanceId = oaProcessInstanceId;
+    }
+
 }

+ 1 - 4
src/main/java/com/malk/minglei/dto/TravelApplicationPushRequest.java

@@ -11,7 +11,7 @@ import java.util.List;
 /**
  * 钉钉 OA 审批通过后推送出差申请流程的请求模型。
  * <p>
- * 所有人员字段接收钉钉 userId,部门字段接收钉钉 departmentId;附件不在本接口处理范围内。
+ * 接口请求仅接收 OA 审批实例 ID。其余字段由服务端查询审批实例详情后组装,附件不在本接口处理范围内。
  */
 public class TravelApplicationPushRequest {
 
@@ -19,13 +19,10 @@ public class TravelApplicationPushRequest {
     @JsonAlias("processInstanceId")
     private String oaProcessInstanceId;
 
-    @NotBlank
     private String userId;
 
-    @NotBlank
     private String departmentId;
 
-    @NotBlank
     private String title;
 
     private String company;

+ 1 - 1
src/main/java/com/malk/minglei/dto/banking/ElectronicBillDownloadRequest.java

@@ -1,7 +1,7 @@
 package com.malk.minglei.dto.banking;
 
 /**
- * 电子回单下载条件。uniqueNumber 与 businessTransNo 必须且只能传入一个。
+ * 电子回单下载条件。uniqueNumber 与 businessTransNo 必须且只能传入一个;异步补偿优先使用流水返回的 uniqueNumber。
  */
 public class ElectronicBillDownloadRequest {
     private String uniqueNumber;

+ 8 - 0
src/main/java/com/malk/minglei/dto/banking/SinglePaymentRequest.java

@@ -22,6 +22,10 @@ public class SinglePaymentRequest {
     private String postscript;
     private String summary;
     private String metaJson;
+    /** 宜搭表单 ID,仅用于支付状态完成后的业务关联,不会传给银企接口。 */
+    private String formID;
+    /** 宜搭表单实例 ID,仅用于支付状态完成后的业务关联,不会传给银企接口。 */
+    private String formInstanceId;
     private Map<String, String> eBopInfo;
     public String getBusinessTransNo() { return businessTransNo; }
     public void setBusinessTransNo(String businessTransNo) { this.businessTransNo = businessTransNo; }
@@ -53,6 +57,10 @@ public class SinglePaymentRequest {
     public void setSummary(String summary) { this.summary = summary; }
     public String getMetaJson() { return metaJson; }
     public void setMetaJson(String metaJson) { this.metaJson = metaJson; }
+    public String getFormID() { return formID; }
+    public void setFormID(String formID) { this.formID = formID; }
+    public String getFormInstanceId() { return formInstanceId; }
+    public void setFormInstanceId(String formInstanceId) { this.formInstanceId = formInstanceId; }
     public Map<String, String> geteBopInfo() { return eBopInfo; }
     public void seteBopInfo(Map<String, String> eBopInfo) { this.eBopInfo = eBopInfo; }
 }

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

@@ -5,6 +5,8 @@ import com.malk.minglei.dto.EmployeeRosterSyncResult;
 import com.malk.minglei.dto.DingTalkDedicatedAccountCreateRequest;
 import com.malk.minglei.dto.DingTalkDedicatedAccountCreateResult;
 
+import java.util.List;
+
 /**
  * 员工花名册同步和查询服务。
  */
@@ -57,4 +59,12 @@ public interface EmployeeRosterService {
      * @return 员工花名册信息,不存在时返回 null
      */
     EmployeeRosterInfo findByUsername(String username);
+
+    /**
+     * 将钉钉部门 ID、部门名称或层级部门路径解析为钉钉部门 ID。
+     *
+     * @param departmentReferences 部门引用列表,支持数字 ID、名称和以 - 或 / 分隔的层级路径
+     * @return 去重后的钉钉部门 ID 列表
+     */
+    List<Long> resolveDingTalkDepartmentIds(List<Object> departmentReferences);
 }

+ 197 - 45
src/main/java/com/malk/minglei/service/impl/banking/BankingServiceImpl.java

@@ -18,6 +18,7 @@ import com.malk.minglei.service.banking.BankingService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
@@ -26,6 +27,8 @@ import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
 import org.springframework.web.client.RestTemplate;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.util.UriUtils;
 
 import java.math.BigDecimal;
 import java.net.URI;
@@ -63,14 +66,40 @@ public class BankingServiceImpl implements BankingService {
     private final RestTemplate restTemplate;
     private final BankingProperties properties;
     private final ObjectMapper objectMapper;
+    private final BankingPaymentTrackingRepository paymentTrackingRepository;
+    private final BankingPaymentYidaWriteBackService yidaWriteBackService;
     private final SecureRandom secureRandom = new SecureRandom();
 
+    /**
+     * 创建银企服务,并接入支付状态补偿记录。
+     */
+    @Autowired
     public BankingServiceImpl(@Qualifier("bankingRestTemplate") RestTemplate restTemplate,
                               BankingProperties properties,
-                              ObjectMapper objectMapper) {
+                              ObjectMapper objectMapper,
+                              BankingPaymentTrackingRepository paymentTrackingRepository,
+                              BankingPaymentYidaWriteBackService yidaWriteBackService) {
         this.restTemplate = restTemplate;
         this.properties = properties;
         this.objectMapper = objectMapper;
+        this.paymentTrackingRepository = paymentTrackingRepository;
+        this.yidaWriteBackService = yidaWriteBackService;
+    }
+
+    public BankingServiceImpl(@Qualifier("bankingRestTemplate") RestTemplate restTemplate,
+                              BankingProperties properties,
+                              ObjectMapper objectMapper,
+                              BankingPaymentTrackingRepository paymentTrackingRepository) {
+        this(restTemplate, properties, objectMapper, paymentTrackingRepository, null);
+    }
+
+    /**
+     * 供既有单元测试构造不依赖数据库的银企服务。
+     */
+    public BankingServiceImpl(@Qualifier("bankingRestTemplate") RestTemplate restTemplate,
+                              BankingProperties properties,
+                              ObjectMapper objectMapper) {
+        this(restTemplate, properties, objectMapper, null, null);
     }
 
     /**
@@ -132,7 +161,8 @@ public class BankingServiceImpl implements BankingService {
         requireRequest(request, "单笔支付请求不能为空");
         require(request.getBusinessTransNo(), "外部交易流水号不能为空");
         requirePositive(request.getPayAmount(), "支付金额必须大于零");
-        require(resolveFromAccountNo(request), "付款账号不能为空");
+        require(request.getFromAccountNo(), "付款账号不能为空,必须由接口传入");
+        require(request.getFromAccountName(), "付款户名不能为空,必须由接口传入");
         require(request.getToAccountNo(), "收款账号不能为空");
         require(request.getToBankName(), "收款银行名称不能为空");
         require(properties.getIntraBankName(), "MINGLEI_BANKING_INTRA_BANK_NAME 未配置");
@@ -140,7 +170,10 @@ public class BankingServiceImpl implements BankingService {
         populateRecipientBankNo(request);
         Map<String, Object> payment = createPaymentPayload(request);
         LOGGER.info("Submitting banking payment businessTransNo={}", request.getBusinessTransNo());
-        return properties.isPaymentEncryptionEnabled() ? postEncryptedPayment(payment) : post(PAYMENT_PATH, payment);
+        Map<String, Object> response = properties.isPaymentEncryptionEnabled()
+                ? postEncryptedPayment(payment) : post(PAYMENT_PATH, payment);
+        saveSubmittedPaymentForTracking(request, response);
+        return response;
     }
 
     /**
@@ -157,9 +190,21 @@ public class BankingServiceImpl implements BankingService {
         result.put("submitResponse", submitResponse);
         if (isSuccessfulBankResponse(submitResponse)) {
             Map<String, Object> paymentStatus = queryPaymentStatus(request.getBusinessTransNo());
+            recordPaymentStatusForTracking(request.getBusinessTransNo(), paymentStatus);
+            String bankPaymentStatus = extractPaymentStatus(paymentStatus);
             result.put("paymentStatus", paymentStatus);
             result.put("statusQuerySkipped", false);
-            downloadElectronicBillAfterSuccessfulPayment(request.getBusinessTransNo(), paymentStatus, result);
+            if (!isPaymentSuccessful(paymentStatus)) {
+                boolean writtenBack = writePaymentResultToYida(request.getFormID(), request.getFormInstanceId(),
+                        bankPaymentStatus, null);
+                if (writtenBack && isTerminalFailure(bankPaymentStatus)) {
+                    markPaymentTrackingTerminalFailure(request.getBusinessTransNo(), bankPaymentStatus);
+                }
+            } else {
+                writePaymentResultToYida(request.getFormID(), request.getFormInstanceId(), bankPaymentStatus, null);
+            }
+            downloadElectronicBillAfterSuccessfulPayment(request.getBusinessTransNo(), request.getFormID(),
+                    request.getFormInstanceId(), paymentStatus, result);
         } else {
             result.put("paymentStatus", null);
             result.put("statusQuerySkipped", true);
@@ -173,7 +218,8 @@ public class BankingServiceImpl implements BankingService {
     /**
      * 回单仅在银行确认支付成功后下载。回单生成可能存在延迟,因此下载失败不影响制单和状态查询结果。
      */
-    private void downloadElectronicBillAfterSuccessfulPayment(String businessTransNo,
+    private void downloadElectronicBillAfterSuccessfulPayment(String businessTransNo, String formID,
+                                                              String formInstanceId,
                                                               Map<String, Object> paymentStatus,
                                                               Map<String, Object> result) {
         if (!isPaymentSuccessful(paymentStatus)) {
@@ -184,15 +230,33 @@ public class BankingServiceImpl implements BankingService {
         }
         try {
             ElectronicBillDownloadRequest receiptRequest = new ElectronicBillDownloadRequest();
-            receiptRequest.setBusinessTransNo(businessTransNo);
-            result.put("electronicBill", downloadElectronicBill(receiptRequest));
+            String uniqueNumber = extractPaymentDataText(paymentStatus, "uniqueNumber");
+            if (StringUtils.hasText(uniqueNumber)) {
+                receiptRequest.setUniqueNumber(uniqueNumber);
+            } else {
+                receiptRequest.setBusinessTransNo(businessTransNo);
+            }
+            ElectronicBillDownloadResult receipt = downloadElectronicBill(receiptRequest);
+            ElectronicBillDownloadResult receiptWithUrls = new ElectronicBillDownloadResult(receipt.getFileName(),
+                    receipt.getFilePath(), receipt.getFileSize(), receipt.getContentType(),
+                    buildElectronicBillFileUrl(receipt.getFileName(), "preview"),
+                    buildElectronicBillFileUrl(receipt.getFileName(), "download"));
+            result.put("electronicBill", receiptWithUrls);
+            if (writePaymentResultToYida(formID, formInstanceId, "X0000", receiptWithUrls)) {
+                markPaymentTrackingCompleted(businessTransNo, receiptWithUrls);
+            } else {
+                markPaymentTrackingReceiptPending(businessTransNo);
+            }
             result.put("receiptDownloadSkipped", false);
             result.put("receiptDownloadMessage", null);
         } catch (RuntimeException exception) {
+            markPaymentTrackingReceiptPending(businessTransNo);
             result.put("electronicBill", null);
             result.put("receiptDownloadSkipped", true);
             result.put("receiptDownloadMessage", "支付成功,但回单暂不可下载:" + exception.getMessage());
-            LOGGER.warn("Electronic bill is not available after successful payment");
+            // 同步组合接口失败后仍由定时任务重试,因此记录完整异常以区分回单未生成和回写失败。
+            LOGGER.warn("Electronic bill is not available after successful payment businessTransNo={}",
+                    businessTransNo, exception);
         }
     }
 
@@ -254,11 +318,19 @@ public class BankingServiceImpl implements BankingService {
         }
 
         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);
+        query.put("uniqueNumber", hasUniqueNumber ? request.getUniqueNumber() : null);
+        query.put("businessTransNo", hasUniqueNumber ? null : request.getBusinessTransNo());
+        URI uri = createGetUri(ELECTRONIC_BILL_DOWNLOAD_PATH, query);
+        LOGGER.info("Downloading banking electronic bill uri={}", uri);
+        ResponseEntity<byte[]> response;
+        try {
+            response = restTemplate.exchange(
+                    uri, HttpMethod.GET, new HttpEntity<Void>(createGetHeaders()), byte[].class);
+        } catch (HttpClientErrorException exception) {
+            LOGGER.warn("Electronic bill download failed status={} uniqueNumber={} businessTransNo={}",
+                    exception.getStatusCode().value(), request.getUniqueNumber(), request.getBusinessTransNo());
+            throw exception;
+        }
         byte[] content = response.getBody();
         if (content == null || content.length == 0) {
             throw new IllegalStateException("银企直连未返回电子回单文件内容");
@@ -273,6 +345,114 @@ public class BankingServiceImpl implements BankingService {
                 contentType == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : contentType.toString());
     }
 
+    /**
+     * 银企制单已成功后保存待查询记录。记录库不可用不会将已成功的制单响应误报为失败,避免调用方重复制单。
+     */
+    private void saveSubmittedPaymentForTracking(SinglePaymentRequest request, Map<String, Object> response) {
+        if (!isSuccessfulBankResponse(response) || paymentTrackingRepository == null) {
+            return;
+        }
+        try {
+            paymentTrackingRepository.saveSubmittedPayment(request);
+        } catch (Exception exception) {
+            LOGGER.error("Banking payment tracking record was not saved businessTransNo={}", request.getBusinessTransNo(),
+                    exception);
+        }
+    }
+
+    private void recordPaymentStatusForTracking(String businessTransNo, Map<String, Object> paymentStatus) {
+        if (paymentTrackingRepository == null) {
+            return;
+        }
+        try {
+            paymentTrackingRepository.recordPaymentStatus(businessTransNo, extractPaymentStatus(paymentStatus));
+        } catch (Exception exception) {
+            LOGGER.error("Banking payment status was not recorded businessTransNo={}", businessTransNo, exception);
+        }
+    }
+
+    private void markPaymentTrackingCompleted(String businessTransNo, ElectronicBillDownloadResult receipt) {
+        if (paymentTrackingRepository == null) {
+            return;
+        }
+        try {
+            paymentTrackingRepository.markCompleted(businessTransNo,
+                    buildElectronicBillFileUrl(receipt.getFileName(), "preview"),
+                    buildElectronicBillFileUrl(receipt.getFileName(), "download"));
+        } catch (RuntimeException exception) {
+            LOGGER.error("Banking electronic bill completion was not recorded businessTransNo={}", businessTransNo,
+                    exception);
+        }
+    }
+
+    private void markPaymentTrackingReceiptPending(String businessTransNo) {
+        if (paymentTrackingRepository == null) {
+            return;
+        }
+        try {
+            paymentTrackingRepository.markReceiptDownloadFailed(businessTransNo);
+        } catch (RuntimeException exception) {
+            LOGGER.error("Banking electronic bill retry state was not recorded businessTransNo={}", businessTransNo,
+                    exception);
+        }
+    }
+
+    private void markPaymentTrackingTerminalFailure(String businessTransNo, String paymentStatus) {
+        if (paymentTrackingRepository == null) {
+            return;
+        }
+        try {
+            paymentTrackingRepository.markTerminalFailure(businessTransNo, paymentStatus);
+        } catch (RuntimeException exception) {
+            LOGGER.error("Banking terminal payment status was not recorded businessTransNo={}", businessTransNo,
+                    exception);
+        }
+    }
+
+    private boolean writePaymentResultToYida(String formID, String formInstanceId, String paymentStatus,
+                                             ElectronicBillDownloadResult receipt) {
+        if (yidaWriteBackService == null) {
+            return true;
+        }
+        try {
+            yidaWriteBackService.writePaymentResult(formID, formInstanceId, paymentStatus, receipt);
+            return true;
+        } catch (RuntimeException exception) {
+            LOGGER.error("Banking payment result was not written back to Yida formID={} formInstanceId={}", formID,
+                    formInstanceId, exception);
+            return false;
+        }
+    }
+
+    private boolean isTerminalFailure(String paymentStatus) {
+        return "X5554".equals(paymentStatus) || "X9997".equals(paymentStatus) || "X9999".equals(paymentStatus);
+    }
+
+    private String extractPaymentStatus(Map<String, Object> response) {
+        Object data = response == null ? null : response.get("data");
+        if (!(data instanceof Map)) {
+            return null;
+        }
+        Object status = ((Map<?, ?>) data).get("transferStatus");
+        return status == null ? null : String.valueOf(status);
+    }
+
+    private String extractPaymentDataText(Map<String, Object> response, String fieldName) {
+        Object data = response == null ? null : response.get("data");
+        if (!(data instanceof Map)) {
+            return null;
+        }
+        Object value = ((Map<?, ?>) data).get(fieldName);
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private String buildElectronicBillFileUrl(String fileName, String option) {
+        return properties.getPublicBaseUrl().replaceAll("/+$", "")
+                + "/api/banking/electronic-bills/files/"
+                + UriUtils.encodePathSegment(fileName, StandardCharsets.UTF_8)
+                + "?option=" + option;
+    }
+
     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 未配置");
@@ -322,8 +502,8 @@ public class BankingServiceImpl implements BankingService {
         putIfHasText(payment, "businessTransNo", request.getBusinessTransNo());
         payment.put("payAmount", request.getPayAmount().toPlainString());
         payment.put("crossBank", determineCrossBank(request.getToBankName()));
-        putIfHasText(payment, "fromAccountNo", resolveFromAccountNo(request));
-        putIfHasText(payment, "fromAccountName", resolveFromAccountName(request));
+        putIfHasText(payment, "fromAccountNo", request.getFromAccountNo());
+        putIfHasText(payment, "fromAccountName", request.getFromAccountName());
         putIfHasText(payment, "toAccountNo", request.getToAccountNo());
         putIfHasText(payment, "toAccountName", request.getToAccountName());
         payment.put("toAccountType", resolveToAccountType(request));
@@ -363,28 +543,6 @@ public class BankingServiceImpl implements BankingService {
         return "X0000".equals(String.valueOf(transferStatus));
     }
 
-    /**
-     * 支付请求优先使用调用方指定的付款账号,缺失时使用银企配置中的默认付款账号。
-     *
-     * @param request 单笔支付制单参数
-     * @return 最终付款账号
-     */
-    private String resolveFromAccountNo(SinglePaymentRequest request) {
-        return StringUtils.hasText(request.getFromAccountNo())
-                ? request.getFromAccountNo() : properties.getDefaultFromAccountNo();
-    }
-
-    /**
-     * 支付请求优先使用调用方指定的付款账户名称,缺失时使用银企配置中的默认名称。
-     *
-     * @param request 单笔支付制单参数
-     * @return 最终付款账户名称
-     */
-    private String resolveFromAccountName(SinglePaymentRequest request) {
-        return StringUtils.hasText(request.getFromAccountName())
-                ? request.getFromAccountName() : properties.getDefaultFromAccountName();
-    }
-
     /**
      * 收款账户类型未传时,按对公账户处理;调用方传入值优先。
      *
@@ -556,14 +714,6 @@ public class BankingServiceImpl implements BankingService {
         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;
@@ -588,6 +738,8 @@ public class BankingServiceImpl implements BankingService {
             fileName = fallbackFileName;
         }
         String sanitizedFileName = fileName.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_").trim();
+        // 文件名会作为 URL 路径段返回给浏览器,空白统一替换,避免代理或客户端解码不一致。
+        sanitizedFileName = sanitizedFileName.replaceAll("[\\s\\p{Z}]+", "_");
         if (!StringUtils.hasText(sanitizedFileName) || ".".equals(sanitizedFileName) || "..".equals(sanitizedFileName)) {
             return "electronic-bill.pdf";
         }

+ 44 - 6
src/main/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImpl.java

@@ -1,7 +1,10 @@
 package com.malk.minglei.service.impl.dictionary;
 
+import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.service.dictionary.DictionaryQueryService;
+import com.malk.minglei.service.employee.EmployeeRosterService;
 import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
 import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
@@ -96,7 +99,7 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
                     "and xmda003 <> 'ML03030000' and xmda003 <> 'ML03020200' and xmdastus = 'Y'";
     private static final String ERP_SALES_ORDER_COUNT_SQL = "select count(1) from (" + ERP_SALES_ORDER_DATA_SQL + ")";
 
-    // 生产令开窗沿用销售订单主明细信息,但按拥有者权限筛选,且不限制未交数量。
+    // 生产令开窗沿用销售订单主明细信息,但按用户所属部门筛选,且不限制未交数量。
     private static final String ERP_PRODUCTION_ORDER_DICTIONARY_CODE = "erp-production-order";
     private static final String ERP_PRODUCTION_ORDER_DATA_SQL =
             "select distinct xmdasite, xmdadocno, to_char(xmdadocdt, 'yyyy-mm-dd') xmdadocdt, xmdcud010, " +
@@ -121,7 +124,9 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
                     "where xmdaent = 86 and xmdasite = :t100id " +
                     "and xmda003 <> 'L0501' and xmda003 <> 'G0899' and xmda003 <> 'MLJT0025' and xmda003 <> 'MLJT0006' " +
                     "and substr(xmdc001, 1, 1) <> '6' and xmda003 <> 'ML03030000' " +
-                    "and xmda003 <> 'ML03020200' and xmdastus = 'Y' and xmdaownid in (:qx)";
+                    "and xmda003 <> 'ML03020200' and xmdastus = 'Y' " +
+                    "and xmdaowndp = (select ooag003 from DSDATA.ooag_t where ooagent = xmdaent " +
+                    "and ooag001 = xmda002 and ooag001 = :employeeNo)";
     private static final String ERP_PRODUCTION_ORDER_COUNT_SQL = "select count(1) from (" + ERP_PRODUCTION_ORDER_DATA_SQL + ")";
 
     // 供应商禁用视图按据点和公司返回当前不可用的供应商记录,并支持按供应商名称筛选。
@@ -292,15 +297,30 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
     private static final String PAGE_ROW_NUMBER_COLUMN = "DICTIONARY_PAGE_ROW_NUM";
 
     private final ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider;
+    private final EmployeeRosterService employeeRosterService;
 
     /**
      * 创建数据字典服务实现。
      *
      * @param jdbcTemplateProvider 数据字典 Oracle JDBC 模板提供器
      */
+    @Autowired
     public DictionaryQueryServiceImpl(
-            @Qualifier("dictionaryNamedParameterJdbcTemplate") ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider) {
+            @Qualifier("dictionaryNamedParameterJdbcTemplate") ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider,
+            EmployeeRosterService employeeRosterService) {
         this.jdbcTemplateProvider = jdbcTemplateProvider;
+        this.employeeRosterService = employeeRosterService;
+    }
+
+    /**
+     * 为既有单元测试保留仅注入 Oracle 查询组件的构造方式。
+     * 生产令开窗必须使用包含员工花名册服务的构造方式。
+     *
+     * @param jdbcTemplateProvider 数据字典 Oracle JDBC 模板提供器
+     */
+    public DictionaryQueryServiceImpl(
+            @Qualifier("dictionaryNamedParameterJdbcTemplate") ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider) {
+        this(jdbcTemplateProvider, null);
     }
 
     /**
@@ -490,19 +510,37 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
     }
 
     /**
-     * 查询指定据点及拥有者权限范围内的生产令开窗数据。
+     * 查询指定据点及指定用户所属部门范围内的生产令开窗数据。
+     * 调用方传入钉钉 userId,服务先转换为 ERP 使用的员工工号后再执行 Oracle 查询。
      *
      * @param filters 调用方传入的筛选条件
      * @return 生产令开窗分页记录
      */
     private Map<String, Object> queryErpProductionOrder(Map<String, String> filters) {
-        validateFilters(filters, ERP_PRODUCTION_ORDER_DICTIONARY_CODE, "qx");
+        validateFilters(filters, ERP_PRODUCTION_ORDER_DICTIONARY_CODE, "userId");
         MapSqlParameterSource parameters = new MapSqlParameterSource();
         parameters.addValue("t100id", requireTextFilter(filters, "t100id"));
-        parameters.addValue("qx", requireCommaSeparatedFilter(filters, "qx"));
+        parameters.addValue("employeeNo", findEmployeeNoByUserId(requireTextFilter(filters, "userId")));
         return queryPage(ERP_PRODUCTION_ORDER_DATA_SQL, ERP_PRODUCTION_ORDER_COUNT_SQL, parameters, createPageRequest());
     }
 
+    /**
+     * 将钉钉用户 ID 转换为 ERP 人员主数据中的员工工号。
+     *
+     * @param userId 钉钉用户 ID
+     * @return 可用于 Oracle 查询的员工工号
+     */
+    private String findEmployeeNoByUserId(String userId) {
+        if (employeeRosterService == null) {
+            throw new IllegalStateException("Employee roster service is not configured");
+        }
+        EmployeeRosterInfo employee = employeeRosterService.findByUserId(userId);
+        if (employee == null || employee.getEmployeeNo() == null || employee.getEmployeeNo().trim().isEmpty()) {
+            throw new IllegalArgumentException("Employee roster employeeNo not found for userId: " + userId);
+        }
+        return employee.getEmployeeNo().trim();
+    }
+
     /**
      * 查询指定据点和公司的禁用供应商视图,并可按供应商名称模糊筛选。
      *

+ 262 - 21
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImpl.java

@@ -6,11 +6,13 @@ 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.approval.DingTalkApprovalClient;
 import com.malk.minglei.service.employee.DingTalkEmployeeRosterClient;
 import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.server.dingtalk.DDConf;
 import com.malk.service.dingtalk.DDClient;
 import com.malk.service.dingtalk.DDClient_Contacts;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -23,6 +25,9 @@ import java.util.Map;
 import java.util.Set;
 import java.time.LocalDate;
 import java.time.ZoneId;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 
 /**
  * 员工花名册同步与查询服务实现。
@@ -36,7 +41,7 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
     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 static final String DEDICATED_ACCOUNT_PASSWORD_PREFIX = "ML";
 
     private final DDClient dingTalkClient;
     private final DDClient_Contacts contactsClient;
@@ -45,6 +50,7 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
     private final EmployeeRosterRepository repository;
     private final EmployeeRosterProperties properties;
     private final EmployeeRosterDuplicateNameNotifier duplicateNameNotifier;
+    private final DingTalkApprovalClient approvalClient;
 
     /**
      * 创建员工花名册服务实现。
@@ -63,6 +69,31 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
                                      EmployeeRosterRepository repository,
                                      EmployeeRosterProperties properties,
                                      EmployeeRosterDuplicateNameNotifier duplicateNameNotifier) {
+        this(dingTalkClient, contactsClient, dingTalkConfiguration, rosterClient, repository, properties,
+                duplicateNameNotifier, null);
+    }
+
+    /**
+     * 创建员工花名册服务实现。
+     *
+     * @param dingTalkClient 钉钉基础客户端
+     * @param contactsClient 钉钉通讯录客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param rosterClient 钉钉花名册客户端
+     * @param repository 员工花名册本地仓储
+     * @param properties 员工花名册同步配置
+     * @param duplicateNameNotifier 同名员工通知组件
+     * @param approvalClient 钉钉 OA 审批实例客户端
+     */
+    @Autowired
+    public EmployeeRosterServiceImpl(DDClient dingTalkClient,
+                                     DDClient_Contacts contactsClient,
+                                     DDConf dingTalkConfiguration,
+                                     DingTalkEmployeeRosterClient rosterClient,
+                                     EmployeeRosterRepository repository,
+                                     EmployeeRosterProperties properties,
+                                     EmployeeRosterDuplicateNameNotifier duplicateNameNotifier,
+                                     DingTalkApprovalClient approvalClient) {
         this.dingTalkClient = dingTalkClient;
         this.contactsClient = contactsClient;
         this.dingTalkConfiguration = dingTalkConfiguration;
@@ -70,6 +101,7 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         this.repository = repository;
         this.properties = properties;
         this.duplicateNameNotifier = duplicateNameNotifier;
+        this.approvalClient = approvalClient;
     }
 
     /**
@@ -88,44 +120,49 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
 
         String accessToken = dingTalkClient.getAccessToken();
         require(accessToken, "Unable to obtain DingTalk access token");
+        DedicatedAccountData account = queryDedicatedAccountData(accessToken,
+                requiredText(request.getOaProcessInstanceId(), "oaProcessInstanceId must not be blank"));
 
-        List<Long> departmentIds = resolveDepartmentIds(accessToken, request.getDepartmentList());
+        List<Long> departmentIds = resolveDepartmentIds(accessToken, account.departmentList);
         if (departmentIds.isEmpty()) {
             throw new IllegalArgumentException("departmentList must contain at least one DingTalk department name or ID");
         }
 
-        String employeeNo = optionalText(request.getEmployeeNo());
+        String employeeNo = optionalText(account.employeeNo);
         if (employeeNo == null) {
             employeeNo = repository.reserveNextEmployeeNo(LocalDate.now(EMPLOYEE_NO_ZONE_ID));
         }
-        String accountCredential = DEDICATED_ACCOUNT_CREDENTIAL_PREFIX + employeeNo;
+        String loginId = employeeNo;
+        String initialPassword = DEDICATED_ACCOUNT_PASSWORD_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());
+        // 钉钉 title 是职位字段;未配置职位时兼容沿用原职称映射。
+        putIfNotBlank(extraFields, "title", firstNotBlank(account.position, account.jobTitle));
+        String gender = normalizeGender(account.gender);
         if (gender != null) {
             extraFields.put("gender", gender);
         }
-        if (request.getExpectedArrivalDate() != null) {
-            extraFields.put("hired_date", Long.valueOf(request.getExpectedArrivalDate()
+        if (account.expectedArrivalDate != null) {
+            extraFields.put("hired_date", Long.valueOf(account.expectedArrivalDate
                     .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, "positionName", account.positionName);
+        putIfNotBlank(extension, "jobTitle", account.jobTitle);
+        if (account.age != null) {
+            extension.put("age", account.age);
         }
-        putIfNotBlank(extension, "jobGrade", request.getJobGrade());
-        putIfNotBlank(extension, "idCardNo", request.getIdCardNo());
+        putIfNotBlank(extension, "jobGrade", account.jobGrade);
+        putIfNotBlank(extension, "idCardNo", account.idCardNo);
         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"),
+                loginId,
+                initialPassword,
+                account.name,
                 departmentIds,
                 extraFields);
         if (response == null) {
@@ -137,11 +174,11 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         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.setUsername(account.name);
+        record.setPositionName(optionalText(account.positionName));
+        record.setHireDate(account.expectedArrivalDate == null ? null : account.expectedArrivalDate.toString());
+        record.setJobTitle(optionalText(account.jobTitle));
+        record.setJobGrade(optionalText(account.jobGrade));
         record.setIsResigned(1);
         if (repository.upsertAll(Collections.singletonList(record)) != 1) {
             throw new IllegalStateException("Failed to persist the newly created employee roster record");
@@ -149,6 +186,192 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         return new DingTalkDedicatedAccountCreateResult(userId, employeeNo, departmentIds);
     }
 
+    /**
+     * 查询 OA 审批实例并将人员表单控件转换为创建账号所需的数据。
+     * 表单字段名称允许使用当前人事表单的中文名称或常见别名,以兼容控件 bizAlias 的调整。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param processInstanceId 钉钉 OA 审批实例 ID
+     * @return 已完成字段校验的账号创建数据
+     */
+    private DedicatedAccountData queryDedicatedAccountData(String accessToken, String processInstanceId) {
+        if (approvalClient == null) {
+            throw new IllegalStateException("DingTalk approval client is not configured");
+        }
+        Map<String, Object> detail = approvalClient.getProcessInstanceDetail(accessToken, processInstanceId);
+        if (detail == null || Boolean.FALSE.equals(detail.get("success"))) {
+            throw new IllegalArgumentException("DingTalk OA approval instance query was not successful");
+        }
+        Object resultValue = detail.get("result");
+        if (!(resultValue instanceof Map)) {
+            throw new IllegalArgumentException("OA approval instance does not contain result data");
+        }
+        Map result = (Map) resultValue;
+        List<Map> components = formComponents(firstNotNull(result.get("formComponentValues"),
+                result.get("form_component_values")));
+        if (components.isEmpty()) {
+            throw new IllegalArgumentException("OA approval instance does not contain formComponentValues");
+        }
+
+        DedicatedAccountData account = new DedicatedAccountData();
+        account.name = requiredComponentText(components, "姓名", "员工姓名", "name");
+        Object departmentValue = requiredComponentValue(components, "部门", "所属部门", "入职部门", "申请部门", "department");
+        account.departmentList = Collections.<Object>singletonList(departmentValue);
+        account.employeeNo = componentText(components, "工号", "员工工号", "员工编号", "jobNumber", "employeeNo");
+        account.positionName = componentText(components, "岗位", "职务", "positionName");
+        account.position = componentText(components, "职位", "position", "positionTitle");
+        account.gender = componentText(components, "性别", "gender");
+        account.age = componentAge(components);
+        account.expectedArrivalDate = componentExpectedArrivalDate(components);
+        account.jobTitle = componentText(components, "职称", "jobTitle");
+        account.jobGrade = componentText(components, "职等", "职级", "jobGrade");
+        account.idCardNo = componentText(components, "身份证号", "身份证号码", "idCardNo");
+        return account;
+    }
+
+    private List<Map> formComponents(Object value) {
+        Object structuredValue = parseJsonValue(value);
+        if (!(structuredValue instanceof Iterable)) {
+            return Collections.emptyList();
+        }
+        List<Map> components = new ArrayList<Map>();
+        for (Object item : (Iterable) structuredValue) {
+            if (item instanceof Map) {
+                components.add((Map) item);
+            }
+        }
+        return components;
+    }
+
+    private String requiredComponentText(List<Map> components, String... aliases) {
+        String value = componentText(components, aliases);
+        if (value == null) {
+            throw new IllegalArgumentException("OA approval instance does not contain required field: " + aliases[0]);
+        }
+        return value;
+    }
+
+    private Object requiredComponentValue(List<Map> components, String... aliases) {
+        Map component = findComponent(components, aliases);
+        if (component == null) {
+            throw new IllegalArgumentException("OA approval instance does not contain required field: " + aliases[0]);
+        }
+        Object value = parseJsonValue(component.get("extValue"));
+        if (isMeaningfulValue(value)) {
+            return value;
+        }
+        value = parseJsonValue(component.get("value"));
+        if (!isMeaningfulValue(value)) {
+            throw new IllegalArgumentException("OA approval instance field must not be blank: " + aliases[0]);
+        }
+        return value;
+    }
+
+    private String componentText(List<Map> components, String... aliases) {
+        Map component = findComponent(components, aliases);
+        if (component == null) {
+            return null;
+        }
+        String value = toDisplayText(parseJsonValue(component.get("value")));
+        return value == null ? toDisplayText(parseJsonValue(component.get("extValue"))) : value;
+    }
+
+    private Integer componentAge(List<Map> components) {
+        String ageText = componentText(components, "年龄", "age");
+        if (ageText == null) {
+            return null;
+        }
+        try {
+            int age = Integer.parseInt(ageText);
+            if (age < 0 || age > 150) {
+                throw new IllegalArgumentException("OA approval field age must be between 0 and 150");
+            }
+            return Integer.valueOf(age);
+        } catch (NumberFormatException exception) {
+            throw new IllegalArgumentException("OA approval field age must be an integer");
+        }
+    }
+
+    private LocalDate componentExpectedArrivalDate(List<Map> components) {
+        String dateText = componentText(components, "预计到岗日期", "预计入职日期", "到岗日期", "expectedArrivalDate");
+        if (dateText == null) {
+            return null;
+        }
+        try {
+            if (dateText.matches("[0-9]{10,13}")) {
+                long timestamp = Long.parseLong(dateText);
+                if (dateText.length() == 10) {
+                    timestamp *= 1000L;
+                }
+                return Instant.ofEpochMilli(timestamp).atZone(EMPLOYEE_NO_ZONE_ID).toLocalDate();
+            }
+            return LocalDate.parse(dateText.length() >= 10 ? dateText.substring(0, 10) : dateText,
+                    DateTimeFormatter.ISO_LOCAL_DATE);
+        } catch (DateTimeParseException | NumberFormatException exception) {
+            throw new IllegalArgumentException("OA approval field 预计到岗日期 must use yyyy-MM-dd or a Unix timestamp");
+        }
+    }
+
+    private Map findComponent(List<Map> components, String... aliases) {
+        for (String alias : aliases) {
+            String normalizedAlias = normalizeKey(alias);
+            for (Map component : components) {
+                String name = firstText(component, "name", "label", "title", "componentName", "bizAlias", "biz_alias");
+                if (normalizeKey(name).equals(normalizedAlias)) {
+                    return component;
+                }
+            }
+        }
+        for (Map component : components) {
+            String name = firstText(component, "name", "label", "title", "componentName", "bizAlias", "biz_alias");
+            String normalizedName = normalizeKey(name);
+            for (String alias : aliases) {
+                if (!normalizedName.isEmpty() && normalizedName.contains(normalizeKey(alias))) {
+                    return component;
+                }
+            }
+        }
+        return null;
+    }
+
+    private Object parseJsonValue(Object value) {
+        if (!(value instanceof String)) {
+            return value;
+        }
+        String text = optionalText((String) value);
+        if (text == null || (!text.startsWith("{") && !text.startsWith("["))) {
+            return text;
+        }
+        try {
+            return JSON.parse(text);
+        } catch (Exception ignored) {
+            return text;
+        }
+    }
+
+    private boolean isMeaningfulValue(Object value) {
+        if (value instanceof Iterable) {
+            return ((Iterable) value).iterator().hasNext();
+        }
+        if (value instanceof Map) {
+            return !((Map) value).isEmpty();
+        }
+        return toDisplayText(value) != null;
+    }
+
+    /**
+     * 复用自建企业账号创建时的部门解析规则,将部门名称或路径转换为钉钉部门 ID。
+     *
+     * @param departmentReferences 部门引用列表
+     * @return 去重后的钉钉部门 ID 列表
+     */
+    @Override
+    public List<Long> resolveDingTalkDepartmentIds(List<Object> departmentReferences) {
+        String accessToken = dingTalkClient.getAccessToken();
+        require(accessToken, "Unable to obtain DingTalk access token");
+        return resolveDepartmentIds(accessToken, departmentReferences);
+    }
+
     private String normalizeGender(String gender) {
         String value = optionalText(gender);
         if (value == null) {
@@ -923,4 +1146,22 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         }
     }
 
+    /**
+     * OA 审批实例解析出的账号创建资料,仅在当前服务内使用,避免把未经审批的数据暴露为接口入参。
+     */
+    private static class DedicatedAccountData {
+
+        private String name;
+        private List<Object> departmentList;
+        private String employeeNo;
+        private String positionName;
+        private String position;
+        private String gender;
+        private Integer age;
+        private LocalDate expectedArrivalDate;
+        private String jobTitle;
+        private String jobGrade;
+        private String idCardNo;
+    }
+
 }

+ 761 - 10
src/main/java/com/malk/minglei/service/impl/port/TravelApplicationPushServiceImpl.java

@@ -1,27 +1,34 @@
 package com.malk.minglei.service.impl.port;
 
 import com.alibaba.fastjson.JSON;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.malk.minglei.config.YidaApplicationsProperties;
 import com.malk.minglei.dto.TravelApplicationPushRequest;
+import com.malk.minglei.service.approval.DingTalkApprovalClient;
+import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.minglei.service.port.TravelApplicationPushService;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
 import com.malk.server.common.McException;
 import com.malk.server.dingtalk.DDConf;
 import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
+import java.math.BigDecimal;
 import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
 /**
- * 将钉钉 OA 已通过的出差审批数据映射到宜搭出差申请流程。
+ * 将钉钉 OA 审批实例详情映射到宜搭出差申请流程。
  * <p>
  * 该实现使用出差申请流程所属的协同办公宜搭应用凭据,人员和部门字段只传递钉钉 ID,避免依赖名称反查。
  */
@@ -87,8 +94,12 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
     private static final String BUDGET_TOTAL_COST_FIELD_ID = "numberField_mvorr6ox";
 
     private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDClient dingTalkClient;
+    private final DingTalkApprovalClient dingTalkApprovalClient;
     private final DDConf dingTalkConfiguration;
     private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
+    private final EmployeeRosterService employeeRosterService;
+    private final ObjectMapper objectMapper;
 
     /**
      * 创建出差申请推送服务。
@@ -96,28 +107,44 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
      * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
      * @param dingTalkConfiguration 钉钉应用配置
      * @param applications 宜搭应用配置
+     * @param employeeRosterService 员工花名册服务
      */
     public TravelApplicationPushServiceImpl(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                            DDClient dingTalkClient,
+                                            DingTalkApprovalClient dingTalkApprovalClient,
                                             DDConf dingTalkConfiguration,
-                                            YidaApplicationsProperties applications) {
+                                            YidaApplicationsProperties applications,
+                                            EmployeeRosterService employeeRosterService,
+                                            ObjectMapper objectMapper) {
         this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.dingTalkClient = dingTalkClient;
+        this.dingTalkApprovalClient = dingTalkApprovalClient;
         this.dingTalkConfiguration = dingTalkConfiguration;
         this.coordinationOfficeConfiguration = applications.getCoordinationOffice();
+        this.employeeRosterService = employeeRosterService;
+        this.objectMapper = objectMapper;
     }
 
     /**
      * 发起新的宜搭出差申请流程。
      *
-     * @param request 已通过 OA 审批的主表和三张子表数据
+     * @param request 仅包含 OA 审批实例 ID 的请求
      * @return OA 审批实例与宜搭流程实例的关联结果
      */
     @Override
     public Map<String, Object> push(TravelApplicationPushRequest request) {
         validateConfiguration();
 
-        String userId = requireText(request.getUserId(), "Travel application userId must not be blank");
-        String title = requireText(request.getTitle(), "Travel application title must not be blank");
-        Map<String, Object> formData = toYidaFormData(request, userId, title);
+        String oaProcessInstanceId = requireText(request.getOaProcessInstanceId(),
+                "Travel application oaProcessInstanceId must not be blank");
+        TravelApplicationPushRequest application = queryTravelApplication(oaProcessInstanceId);
+        String userId = requireText(application.getUserId(), "OA approval instance originator userId must not be blank");
+        String title = requireText(application.getTitle(), "OA approval instance title must not be blank");
+        Map<String, Object> formData = toYidaFormData(application, userId, title);
+        // 临时核对 OA 值是否已转换为宜搭字段;确认线上部署后可按需关闭该日志。
+        LOGGER.info("Travel application parsed fields processInstanceId={} transportation={} tripMode={} itineraryRows={}",
+                oaProcessInstanceId, application.getTransportation(), application.getTripMode(),
+                JSON.toJSONString(formData.get(ITINERARY_TABLE_FIELD_ID)));
         Map<String, Object> body = new LinkedHashMap<String, Object>();
         // 流程编码只在协同办公应用内有效,必须使用该应用的 appType 和 systemToken 发起流程。
         body.put("appType", requireText(coordinationOfficeConfiguration.getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured"));
@@ -133,7 +160,7 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
 
         // 仅记录追踪 ID、发起人和子表数量,避免将 OA 审批数据写入日志。
         LOGGER.info("Starting travel application from OA processInstanceId={} userId={} itineraryCount={} flightCount={} budgetCount={}",
-                request.getOaProcessInstanceId(), userId, sizeOf(request.getItineraries()), sizeOf(request.getFlights()), sizeOf(request.getBudgets()));
+                oaProcessInstanceId, userId, sizeOf(application.getItineraries()), sizeOf(application.getFlights()), sizeOf(application.getBudgets()));
         String processInstanceId;
         try {
             processInstanceId = extractProcessInstanceId(yidaProcessOpenApiClient.startProcess(body));
@@ -144,18 +171,707 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
         }
 
         Map<String, Object> result = new LinkedHashMap<String, Object>();
-        result.put("oaProcessInstanceId", request.getOaProcessInstanceId());
+        result.put("oaProcessInstanceId", oaProcessInstanceId);
         result.put("yidaProcessInstanceId", processInstanceId);
         result.put("formUuid", FORM_UUID);
         return result;
     }
 
+    /**
+     * 调用钉钉审批实例详情接口,并将 OA 表单组件转换为既有的宜搭字段映射模型。
+     * 钉钉明细控件可能以对象、数组或 JSON 字符串返回,因此统一先按 JSON 节点解析。
+     *
+     * @param oaProcessInstanceId 钉钉 OA 审批实例 ID
+     * @return 已填充主表和三张子表的内部申请模型
+     */
+    private TravelApplicationPushRequest queryTravelApplication(String oaProcessInstanceId) {
+        String accessToken = requireText(dingTalkClient.getAccessToken(), "Unable to obtain DingTalk access token");
+        Map<String, Object> detail = dingTalkApprovalClient.getProcessInstanceDetail(accessToken, oaProcessInstanceId);
+        // 临时排查 OA 字段结构:详情响应不包含 accessToken,仅输出钉钉原始审批 JSON。
+        LOGGER.info("DingTalk OA approval raw detail processInstanceId={} payload={}",
+                oaProcessInstanceId, JSON.toJSONString(detail));
+        if (Boolean.FALSE.equals(detail.get("success"))) {
+            throw new IllegalArgumentException("DingTalk OA approval instance query was not successful");
+        }
+        Map<String, Object> instance = objectMap(detail.get("result"));
+        if (instance.isEmpty()) {
+            throw new IllegalArgumentException("OA approval instance does not contain result data");
+        }
+
+        List<Map<String, Object>> components = componentList(instance.get("formComponentValues"));
+        if (components.isEmpty()) {
+            components = componentList(instance.get("form_component_values"));
+        }
+        if (components.isEmpty()) {
+            throw new IllegalArgumentException("OA approval instance does not contain formComponentValues");
+        }
+
+        TravelApplicationPushRequest application = new TravelApplicationPushRequest();
+        application.setOaProcessInstanceId(oaProcessInstanceId);
+        application.setUserId(firstText(instance, "originatorUserId", "originator_userid", "originatorUserid"));
+        if (isBlank(application.getUserId())) {
+            List<String> applicantIds = componentUserIds(components, "申请人");
+            if (!applicantIds.isEmpty()) {
+                application.setUserId(applicantIds.get(0));
+            }
+        }
+        application.setTitle(firstText(instance, "title"));
+        application.setCompany(componentText(components, "申请公司", "申请公司名称", "所属公司", "公司"));
+        application.setDepartmentId(componentDepartment(components, "申请部门", "部门"));
+        application.setApplyDate(componentText(components, "申请日期", "申请时间", "出差申请日期"));
+        if (isBlank(application.getApplyDate())) {
+            application.setApplyDate(firstText(instance, "createTime", "create_time"));
+        }
+        application.setPosition(componentText(components, "申请人职位", "申请人职务", "职位", "职务", "岗位"));
+        application.setMobile(componentText(components, "手机号", "手机号码", "联系电话"));
+        application.setActualTravelerUserIds(componentUserIds(components, "实际出差人", "实际出行人", "出差人", "出行人"));
+        application.setEmployeeNo(componentText(components, "工号", "人员ID", "员工编号", "员工工号"));
+        application.setJobGrade(componentText(components, "职等", "职级"));
+        application.setTravelType(componentText(components, "出差类型", "出差性质", "出差方式"));
+        application.setCustomerName(componentText(components, "关联客户", "客户名称", "客户", "客户/项目"));
+        application.setTravelReason(componentText(components, "出差事由", "出差原因", "事由"));
+        application.setTransportation(componentText(components, "交通工具"));
+        application.setTripMode(componentText(components, "单程往返", "是否往返", "往返类型"));
+        // 钉钉 OA 当前返回的控件标题为“日程”,bizAlias 为 itinerary;同时保留历史标题兼容。
+        List<Map<String, String>> itineraryRows = componentRows(components,
+                "行程", "行程明细", "行程数据", "日程", "itinerary");
+        List<TravelApplicationPushRequest.Itinerary> itineraries = toItineraries(itineraryRows);
+        if (itineraries.isEmpty()) {
+            TravelApplicationPushRequest.Itinerary itinerary = flatItinerary(components);
+            if (hasItineraryValue(itinerary)) {
+                itineraries.add(itinerary);
+            }
+        }
+        if (isBlank(application.getTransportation()) && !itineraryRows.isEmpty()) {
+            application.setTransportation(componentRowsText(itineraryRows, "交通工具", "vehicle"));
+        }
+        if (isBlank(application.getTripMode()) && !itineraryRows.isEmpty()) {
+            application.setTripMode(componentRowsText(itineraryRows, "单程往返", "是否往返", "singleOrReturn"));
+        }
+        application.setItineraries(itineraries);
+
+        List<Map<String, String>> flightRows = componentRows(components, "飞机航程", "机票航程", "机票信息");
+        List<TravelApplicationPushRequest.Flight> flights = toFlights(flightRows);
+        String ticketReserved = componentText(components, "是否预定机票", "是否预订机票");
+        if (isBlank(ticketReserved)) {
+            ticketReserved = componentRowsText(flightRows, "是否预定机票", "是否预订机票");
+        }
+        applyFlightDefaults(flights, ticketReserved);
+        application.setFlights(flights);
+        application.setBudgets(toBudgets(componentRows(components, "费用预算", "预算明细")));
+        return application;
+    }
+
+    /**
+     * OA 的“行程”在当前表单中由主表的多个字段组成,而不是明细控件。
+     * 当未读取到行程明细行时,将这些字段合成为一条宜搭行程记录。
+     */
+    private TravelApplicationPushRequest.Itinerary flatItinerary(List<Map<String, Object>> components) {
+        TravelApplicationPushRequest.Itinerary value = new TravelApplicationPushRequest.Itinerary();
+        value.setDepartureCity(componentText(components, "出发地", "出发城市", "出发地点"));
+        value.setDestinationCity(componentText(components, "目的地", "目的城市", "到达地点"));
+        String startTime = componentText(components, "开始时间", "出发时间");
+        String startDate = componentText(components, "开始日期", "出发日期");
+        value.setStartDate(firstNonBlank(datePart(startTime), datePart(startDate)));
+        value.setStartPeriod(firstNonBlank(periodPart(startTime), periodPart(startDate)));
+        String endTime = componentText(components, "结束时间", "返回时间");
+        String endDate = componentText(components, "结束日期", "返回日期");
+        value.setEndDate(firstNonBlank(datePart(endTime), datePart(endDate)));
+        value.setEndPeriod(firstNonBlank(periodPart(endTime), periodPart(endDate)));
+        value.setDurationDays(decimalValue(componentText(components, "时长(天)", "时长", "时长天数")));
+        value.setTravelDays(decimalValue(componentText(components, "出差天数")));
+        value.setRemark(componentText(components, "行程备注", "出差备注", "备注"));
+        value.setCompanionUserIds(componentUserIds(components, "出行人(同行人)", "出行人", "同行人", "同行人员"));
+        value.setExternalPersonnel(componentText(components, "外部人员"));
+        return value;
+    }
+
+    private void applyFlightDefaults(List<TravelApplicationPushRequest.Flight> flights, String ticketReserved) {
+        if (isBlank(ticketReserved)) {
+            return;
+        }
+        if (flights.isEmpty()) {
+            TravelApplicationPushRequest.Flight flight = new TravelApplicationPushRequest.Flight();
+            flight.setTicketReserved(ticketReserved);
+            flights.add(flight);
+            return;
+        }
+        for (TravelApplicationPushRequest.Flight flight : flights) {
+            if (isBlank(flight.getTicketReserved())) {
+                flight.setTicketReserved(ticketReserved);
+            }
+        }
+    }
+
+    private String datePart(String value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\d{4}[-/]\\d{2}[-/]\\d{2}").matcher(value);
+        return matcher.find() ? matcher.group().replace('/', '-') : value;
+    }
+
+    private String periodPart(String value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        if (value.contains("上午")) {
+            return "上午";
+        }
+        if (value.contains("下午")) {
+            return "下午";
+        }
+        return null;
+    }
+
+    private List<TravelApplicationPushRequest.Itinerary> toItineraries(List<Map<String, String>> rows) {
+        List<TravelApplicationPushRequest.Itinerary> values = new ArrayList<TravelApplicationPushRequest.Itinerary>();
+        for (Map<String, String> row : rows) {
+            TravelApplicationPushRequest.Itinerary value = new TravelApplicationPushRequest.Itinerary();
+            value.setDepartureCity(rowText(row, "出发城市", "出发地", "出发地点", "departure"));
+            value.setDestinationCity(rowText(row, "目的城市", "目的地", "到达城市", "到达地点", "arrival"));
+            String start = rowText(row, "开始时间", "出发时间", "startTime");
+            String startDate = rowText(row, "开始日期", "出发日期", "startDate");
+            value.setStartDate(firstNonBlank(datePart(start), datePart(startDate)));
+            value.setStartPeriod(firstNonBlank(rowText(row, "开始时间上午下午", "开始时段", "startPeriod"), periodPart(start), periodPart(startDate)));
+            String end = rowText(row, "结束时间", "返回时间", "endTime");
+            String endDate = rowText(row, "结束日期", "返回日期", "endDate");
+            value.setEndDate(firstNonBlank(datePart(end), datePart(endDate)));
+            value.setEndPeriod(firstNonBlank(rowText(row, "结束时间上午下午", "结束时段", "endPeriod"), periodPart(end), periodPart(endDate)));
+            value.setDurationDays(decimalValue(rowText(row, "时长(天)", "时长", "时长天数", "duration")));
+            value.setTravelDays(decimalValue(rowText(row, "出差天数", "天数", "travelDays")));
+            value.setRemark(rowText(row, "出差备注", "行程备注", "备注", "remark"));
+            value.setCompanionUserIds(rowUserIds(row, "出行人(同行人)", "出行人", "同行人", "同行人员", "companions"));
+            value.setExternalPersonnel(rowText(row, "外部人员", "externalPersonnel"));
+            if (hasItineraryValue(value)) {
+                values.add(value);
+            }
+        }
+        return values;
+    }
+
+    private List<TravelApplicationPushRequest.Flight> toFlights(List<Map<String, String>> rows) {
+        List<TravelApplicationPushRequest.Flight> values = new ArrayList<TravelApplicationPushRequest.Flight>();
+        for (Map<String, String> row : rows) {
+            TravelApplicationPushRequest.Flight value = new TravelApplicationPushRequest.Flight();
+            value.setTicketReserved(rowText(row, "是否预定机票", "是否预订机票", "是否需要预订机票"));
+            value.setTravelRegion(rowText(row, "出差区域类型", "出差区域", "区域类型"));
+            value.setTicketType(rowText(row, "机票类型", "机票单程往返"));
+            value.setTripCount(integerValue(rowText(row, "次数")));
+            value.setBudgetAmount(decimalValue(rowText(row, "预算金额")));
+            value.setUnitPrice(decimalValue(rowText(row, "单价")));
+            value.setOverStandard(rowText(row, "是否超标"));
+            value.setStandardAmount(decimalValue(rowText(row, "标准金额")));
+            value.setRemark(rowText(row, "备注"));
+            if (hasFlightValue(value)) {
+                values.add(value);
+            }
+        }
+        return values;
+    }
+
+    private List<TravelApplicationPushRequest.Budget> toBudgets(List<Map<String, String>> rows) {
+        List<TravelApplicationPushRequest.Budget> values = new ArrayList<TravelApplicationPushRequest.Budget>();
+        for (Map<String, String> row : rows) {
+            TravelApplicationPushRequest.Budget value = new TravelApplicationPushRequest.Budget();
+            value.setDepartureLocation(rowText(row, "出发地点"));
+            value.setArrivalLocation(rowText(row, "到达地点"));
+            value.setAccommodationDays(decimalValue(rowText(row, "住宿天数")));
+            value.setGroundTransportationCost(decimalValue(rowText(row, "交通费用(除飞机外)", "交通费用", "交通费", "地面交通费")));
+            value.setAccommodationCost(decimalValue(rowText(row, "住宿费")));
+            value.setAccommodationStandard(decimalValue(rowText(row, "住宿费标准", "住宿标准", "住宿标准金额")));
+            value.setBusinessEntertainmentCost(decimalValue(rowText(row, "业务招待费")));
+            value.setTravelAllowance(decimalValue(rowText(row, "出差补贴")));
+            value.setTotalCost(decimalValue(rowText(row, "费用合计", "合计")));
+            if (hasBudgetValue(value)) {
+                values.add(value);
+            }
+        }
+        return values;
+    }
+
+    private List<Map<String, Object>> componentList(Object source) {
+        List<Map<String, Object>> components = new ArrayList<Map<String, Object>>();
+        JsonNode node = jsonNode(source);
+        if (!node.isArray()) {
+            return components;
+        }
+        for (JsonNode item : node) {
+            Map<String, Object> component = objectMap(item);
+            if (!component.isEmpty()) {
+                components.add(component);
+            }
+        }
+        return components;
+    }
+
+    private Map<String, Object> objectMap(Object source) {
+        JsonNode node = jsonNode(source);
+        if (!node.isObject()) {
+            return Collections.emptyMap();
+        }
+        return objectMapper.convertValue(node, Map.class);
+    }
+
+    private String firstText(Map<String, Object> source, String... keys) {
+        for (String key : keys) {
+            String value = jsonText(source.get(key));
+            if (!isBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private String componentText(List<Map<String, Object>> components, String... aliases) {
+        Map<String, Object> component = findComponent(components, aliases);
+        if (component == null) {
+            return null;
+        }
+        String value = jsonText(component.get("value"));
+        return isBlank(value) ? jsonText(component.get("extValue")) : value;
+    }
+
+    private String componentDepartment(List<Map<String, Object>> components, String... aliases) {
+        Map<String, Object> component = findComponent(components, aliases);
+        if (component == null) {
+            return null;
+        }
+        String departmentId = firstIdentifier(component.get("extValue"), "id", "deptId", "departmentId", "itemId");
+        return isBlank(departmentId) ? jsonText(component.get("value")) : departmentId;
+    }
+
+    private List<String> componentUserIds(List<Map<String, Object>> components, String... aliases) {
+        Map<String, Object> component = findComponent(components, aliases);
+        if (component == null) {
+            return Collections.emptyList();
+        }
+        List<String> userIds = identifiers(component.get("extValue"), "userid", "userId", "emplId", "employeeId", "itemId", "id", "user_id", "dingTalkUserId");
+        return userIds.isEmpty() ? identifiers(component.get("value"), "userid", "userId", "emplId", "employeeId", "itemId", "id", "user_id", "dingTalkUserId") : userIds;
+    }
+
+    private Map<String, Object> findComponent(List<Map<String, Object>> components, String... aliases) {
+        // 先精确匹配,避免“实际出差人”被“出行人(同行人)”等相近名称抢先命中。
+        for (String alias : aliases) {
+            String normalizedAlias = normalize(alias);
+            for (Map<String, Object> component : components) {
+                String name = firstText(component, "name", "label", "title", "componentName", "bizAlias", "biz_alias");
+                if (normalize(name).equals(normalizedAlias)) {
+                    return component;
+                }
+            }
+        }
+        for (Map<String, Object> component : components) {
+            String name = firstText(component, "name", "label", "title", "componentName", "bizAlias", "biz_alias");
+            String normalizedName = normalize(name);
+            boolean matched = false;
+            for (String alias : aliases) {
+                if (normalizedName.contains(normalize(alias))) {
+                    matched = true;
+                    break;
+                }
+            }
+            if (matched) {
+                return component;
+            }
+        }
+        return null;
+    }
+
+    private List<Map<String, String>> componentRows(List<Map<String, Object>> components, String... aliases) {
+        Map<String, Object> component = findComponent(components, aliases);
+        List<Map<String, String>> rows = new ArrayList<Map<String, String>>();
+        if (component != null) {
+            collectRows(jsonNode(component.get("value")), rows);
+            if (rows.isEmpty()) {
+                collectRows(jsonNode(component.get("extValue")), rows);
+            }
+        }
+        // 钉钉审批详情中明细控件的 name/label 可能是控件标题、空值或版本化名称。
+        // 如果外层标题没有匹配到,则扫描所有控件,并依据行内 bizAlias/字段名识别目标子表。
+        if (rows.isEmpty()) {
+            for (Map<String, Object> candidate : components) {
+                List<Map<String, String>> candidateRows = new ArrayList<Map<String, String>>();
+                collectRows(jsonNode(candidate.get("value")), candidateRows);
+                if (candidateRows.isEmpty()) {
+                    collectRows(jsonNode(candidate.get("extValue")), candidateRows);
+                }
+                if (matchesRowsForComponent(candidateRows, aliases)) {
+                    rows.addAll(candidateRows);
+                    break;
+                }
+            }
+        }
+        return rows;
+    }
+
+    private boolean matchesRowsForComponent(List<Map<String, String>> rows, String... aliases) {
+        if (rows == null || rows.isEmpty()) {
+            return false;
+        }
+        boolean itinerary = containsAlias(aliases, "行程", "行程明细", "行程数据");
+        boolean flight = containsAlias(aliases, "飞机航程", "机票航程", "机票信息");
+        boolean budget = containsAlias(aliases, "费用预算", "预算明细");
+        for (Map<String, String> row : rows) {
+            // 费用预算也可能包含“出发地点/到达地点”,因此行程识别至少要求
+            // 开始时间、结束时间或时长等行程专有字段,不能只按城市字段判断。
+            if (itinerary && (rowText(row, "startTime", "开始时间", "出发时间") != null
+                    || rowText(row, "endTime", "结束时间", "返回时间") != null
+                    || rowText(row, "duration", "时长", "时长(天)", "时长天数") != null)) {
+                return true;
+            }
+            if (flight && (rowText(row, "travelRegion", "出差区域类型", "出差区域") != null
+                    || rowText(row, "ticketType", "机票类型") != null
+                    || rowText(row, "budgetAmount", "预算金额") != null)) {
+                return true;
+            }
+            if (budget && (rowText(row, "departureLocation", "出发地点") != null
+                    || rowText(row, "arrivalLocation", "到达地点") != null
+                    || rowText(row, "accommodationDays", "住宿天数") != null)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean containsAlias(String[] aliases, String... expected) {
+        if (aliases == null) {
+            return false;
+        }
+        for (String alias : aliases) {
+            for (String value : expected) {
+                if (normalize(alias).equals(normalize(value))) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private void collectRows(JsonNode node, List<Map<String, String>> rows) {
+        if (node == null || node.isNull()) {
+            return;
+        }
+        // extendValue/list/rowValue 在部分钉钉版本中会再包一层 JSON 字符串。
+        if (node.isTextual()) {
+            String text = node.asText().trim();
+            if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
+                collectRows(jsonNode(text), rows);
+            }
+            return;
+        }
+        if (node.isValueNode()) {
+            return;
+        }
+        if (node.isArray()) {
+            // 钉钉明细控件可能直接返回 [{name/label,value}, ...],这代表一行而不是多行。
+            if (isNamedFieldArray(node)) {
+                Map<String, String> row = namedValues(node);
+                if (!row.isEmpty()) {
+                    rows.add(row);
+                }
+                return;
+            }
+            for (JsonNode item : node) {
+                collectRows(item, rows);
+            }
+            return;
+        }
+
+        for (String rowField : Arrays.asList("rowValue", "rowValues")) {
+            JsonNode rowValues = node.get(rowField);
+            if (rowValues != null) {
+                Map<String, String> row = namedValues(jsonNode(rowValues));
+                if (!row.isEmpty()) {
+                    rows.add(row);
+                }
+                return;
+            }
+        }
+
+        Map<String, String> row = namedValues(node);
+        if (!row.isEmpty()) {
+            rows.add(row);
+            return;
+        }
+        node.elements().forEachRemaining(child -> collectRows(child, rows));
+    }
+
+    private Map<String, String> namedValues(JsonNode node) {
+        Map<String, String> values = new LinkedHashMap<String, String>();
+        JsonNode componentValues = node != null && node.isArray()
+                ? node : firstArray(node, "rowValue", "rowValues", "componentValues", "formComponentValues", "component_values");
+        if (componentValues != null) {
+            for (JsonNode component : componentValues) {
+                String value = jsonText(component.get("value"));
+                if (isBlank(value)) {
+                    value = rawJsonText(component.get("extValue"));
+                }
+                if (!isBlank(value)) {
+                    // 同时保存 label/name 与 bizAlias。不同钉钉接口版本返回的字段名称不一致,
+                    // 业务层可用中文标题或稳定的 bizAlias(如 departure、arrival)读取。
+                    String label = firstNodeText(component, "label", "name", "title", "componentName", "fieldName");
+                    String bizAlias = firstNodeText(component, "bizAlias", "biz_alias");
+                    if (!isBlank(label)) {
+                        values.put(normalize(label), value);
+                    }
+                    if (!isBlank(bizAlias)) {
+                        values.put(normalize(bizAlias), value);
+                    }
+                }
+            }
+            return values;
+        }
+
+        node.fields().forEachRemaining(entry -> {
+            if (entry.getValue().isValueNode()) {
+                String value = jsonText(entry.getValue());
+                if (!isBlank(value)) {
+                    values.put(normalize(entry.getKey()), value);
+                }
+            }
+        });
+        return values;
+    }
+
+    private boolean isNamedFieldArray(JsonNode node) {
+        if (node == null || !node.isArray() || node.isEmpty()) {
+            return false;
+        }
+        for (JsonNode item : node) {
+            if (item == null || !item.isObject()
+                    || isBlank(firstNodeText(item, "name", "label", "title", "componentName", "bizAlias", "biz_alias", "fieldName"))
+                    || (item.get("value") == null && item.get("extValue") == null)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private String rawJsonText(JsonNode node) {
+        if (node == null || node.isNull() || node.isMissingNode()) {
+            return null;
+        }
+        if (node.isTextual()) {
+            return node.asText();
+        }
+        return node.toString();
+    }
+
+    private JsonNode firstArray(JsonNode node, String... names) {
+        for (String name : names) {
+            JsonNode value = node.get(name);
+            if (value != null && value.isArray()) {
+                return value;
+            }
+            if (value != null && value.isTextual()) {
+                JsonNode parsed = jsonNode(value.asText());
+                if (parsed.isArray()) {
+                    return parsed;
+                }
+            }
+        }
+        return null;
+    }
+
+    private boolean isTravelDetailRow(Map<String, String> row) {
+        return rowText(row, "出发城市", "出发地", "出发地点", "目的城市", "目的地",
+                "是否预定机票", "是否预订机票", "出差区域类型", "预算金额", "住宿天数",
+                "交通费用(除飞机外)", "费用合计") != null;
+    }
+
+    private String componentRowsText(List<Map<String, String>> rows, String... aliases) {
+        for (Map<String, String> row : rows) {
+            String value = rowText(row, aliases);
+            if (!isBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private String rowText(Map<String, String> row, String... aliases) {
+        // 先精确匹配,避免“开始时间”被“开始时间上午下午”这类更长字段误命中。
+        for (String alias : aliases) {
+            String normalizedAlias = normalize(alias);
+            for (Map.Entry<String, String> entry : row.entrySet()) {
+                if (normalize(entry.getKey()).equals(normalizedAlias) && !isBlank(entry.getValue())) {
+                    return entry.getValue();
+                }
+            }
+        }
+        for (Map.Entry<String, String> entry : row.entrySet()) {
+            String normalizedSource = normalize(entry.getKey());
+            for (String alias : aliases) {
+                String normalizedAlias = normalize(alias);
+                if (!isBlank(entry.getValue()) && normalizedSource.contains(normalizedAlias)) {
+                    return entry.getValue();
+                }
+            }
+        }
+        return null;
+    }
+
+    private List<String> rowUserIds(Map<String, String> row, String... aliases) {
+        String value = rowText(row, aliases);
+        return isBlank(value) ? Collections.<String>emptyList()
+                : identifiers(value, "userid", "userId", "emplId", "employeeId", "itemId", "id", "user_id", "dingTalkUserId");
+    }
+
+    private boolean hasItineraryValue(TravelApplicationPushRequest.Itinerary value) {
+        return !isBlank(value.getDepartureCity()) || !isBlank(value.getDestinationCity()) || !isBlank(value.getStartDate())
+                || !isBlank(value.getEndDate()) || !isBlank(value.getRemark()) || value.getDurationDays() != null
+                || value.getTravelDays() != null || !value.getCompanionUserIds().isEmpty()
+                || !isBlank(value.getExternalPersonnel());
+    }
+
+    private boolean hasFlightValue(TravelApplicationPushRequest.Flight value) {
+        return !isBlank(value.getTicketReserved()) || !isBlank(value.getTravelRegion()) || !isBlank(value.getTicketType())
+                || value.getTripCount() != null || value.getBudgetAmount() != null || value.getUnitPrice() != null
+                || value.getStandardAmount() != null || !isBlank(value.getOverStandard()) || !isBlank(value.getRemark());
+    }
+
+    private boolean hasBudgetValue(TravelApplicationPushRequest.Budget value) {
+        return !isBlank(value.getDepartureLocation()) || !isBlank(value.getArrivalLocation())
+                || value.getAccommodationDays() != null || value.getGroundTransportationCost() != null
+                || value.getAccommodationCost() != null || value.getAccommodationStandard() != null
+                || value.getBusinessEntertainmentCost() != null || value.getTravelAllowance() != null
+                || value.getTotalCost() != null;
+    }
+
+    private BigDecimal decimalValue(String value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        try {
+            return new BigDecimal(value.trim());
+        } catch (NumberFormatException exception) {
+            return null;
+        }
+    }
+
+    private Integer integerValue(String value) {
+        BigDecimal decimal = decimalValue(value);
+        return decimal == null ? null : Integer.valueOf(decimal.intValue());
+    }
+
+    private List<String> identifiers(Object source, String... keys) {
+        List<String> values = new ArrayList<String>();
+        collectIdentifiers(jsonNode(source), values, Arrays.asList(keys));
+        return values;
+    }
+
+    private String firstIdentifier(Object source, String... keys) {
+        List<String> values = identifiers(source, keys);
+        return values.isEmpty() ? null : values.get(0);
+    }
+
+    private void collectIdentifiers(JsonNode node, List<String> values, List<String> keys) {
+        if (node == null || node.isNull()) {
+            return;
+        }
+        if (node.isValueNode()) {
+            if (node.isTextual() && !node.asText().trim().startsWith("{") && !node.asText().trim().startsWith("[")) {
+                return;
+            }
+            return;
+        }
+        if (node.isArray()) {
+            for (JsonNode item : node) {
+                collectIdentifiers(item, values, keys);
+            }
+            return;
+        }
+        node.fields().forEachRemaining(entry -> {
+            if (keys.contains(entry.getKey()) && entry.getValue().isValueNode()) {
+                String value = jsonText(entry.getValue());
+                if (!isBlank(value) && !values.contains(value)) {
+                    values.add(value);
+                }
+            } else {
+                collectIdentifiers(entry.getValue(), values, keys);
+            }
+        });
+    }
+
+    private JsonNode jsonNode(Object source) {
+        if (source == null) {
+            return objectMapper.nullNode();
+        }
+        if (source instanceof String) {
+            String text = ((String) source).trim();
+            if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
+                try {
+                    return objectMapper.readTree(text);
+                } catch (Exception ignored) {
+                    // 非 JSON 字符串按普通文本处理。
+                }
+            }
+        }
+        return objectMapper.valueToTree(source);
+    }
+
+    private String jsonText(Object source) {
+        JsonNode node = jsonNode(source);
+        if (node.isNull() || node.isMissingNode()) {
+            return null;
+        }
+        if (node.isValueNode()) {
+            return node.asText();
+        }
+        if (node.isArray()) {
+            for (JsonNode item : node) {
+                String value = jsonText(item);
+                if (!isBlank(value)) {
+                    return value;
+                }
+            }
+            return null;
+        }
+        return firstNodeText(node, "value", "name", "text", "label");
+    }
+
+    private String firstNodeText(JsonNode node, String... keys) {
+        if (node == null) {
+            return null;
+        }
+        for (String key : keys) {
+            JsonNode value = node.get(key);
+            if (value != null) {
+                String text = jsonText(value);
+                if (!isBlank(text)) {
+                    return text;
+                }
+            }
+        }
+        return null;
+    }
+
+    private boolean matchesField(String source, String... aliases) {
+        if (isBlank(source)) {
+            return false;
+        }
+        String normalizedSource = normalize(source);
+        for (String alias : aliases) {
+            String normalizedAlias = normalize(alias);
+            if (normalizedSource.equals(normalizedAlias) || normalizedSource.contains(normalizedAlias)
+                    || normalizedAlias.contains(normalizedSource)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String normalize(String value) {
+        return value == null ? "" : value.replaceAll("[^\\p{IsHan}A-Za-z0-9]", "").toLowerCase();
+    }
+
     private Map<String, Object> toYidaFormData(TravelApplicationPushRequest request, String userId, String title) {
         Map<String, Object> formData = new LinkedHashMap<String, Object>();
         putIfPresent(formData, TITLE_FIELD_ID, title);
         putIfPresent(formData, APPLICANT_FIELD_ID, Collections.singletonList(userId));
         putIfPresent(formData, COMPANY_FIELD_ID, request.getCompany());
-        putIfPresent(formData, DEPARTMENT_FIELD_ID, Collections.singletonList(requireText(request.getDepartmentId(), "Travel application departmentId must not be blank")));
+        putIfPresent(formData, DEPARTMENT_FIELD_ID, resolveDepartmentIds(request.getDepartmentId()));
         putIfPresent(formData, APPLY_DATE_FIELD_ID, toDateMillis(request.getApplyDate()));
         putIfPresent(formData, POSITION_FIELD_ID, request.getPosition());
         putIfPresent(formData, MOBILE_FIELD_ID, request.getMobile());
@@ -175,6 +891,32 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
         return formData;
     }
 
+    /**
+     * 将 OA 的部门名称或 ID 转换为宜搭部门字段所需的钉钉部门 ID 字符串列表。
+     *
+     * @param departmentReference OA 传入的部门名称、层级路径或部门 ID
+     * @return 宜搭部门字段值
+     */
+    private List<String> resolveDepartmentIds(String departmentReference) {
+        String text = requireText(departmentReference, "Travel application departmentId must not be blank");
+        List<Object> references = new ArrayList<Object>();
+        references.add(text);
+        List<Long> departmentIds = employeeRosterService.resolveDingTalkDepartmentIds(references);
+        if (departmentIds == null || departmentIds.isEmpty()) {
+            throw new IllegalArgumentException("Travel application departmentId does not resolve to a DingTalk department ID");
+        }
+        List<String> result = new ArrayList<String>();
+        for (Long departmentId : departmentIds) {
+            if (departmentId != null && departmentId.longValue() > 0) {
+                result.add(String.valueOf(departmentId));
+            }
+        }
+        if (result.isEmpty()) {
+            throw new IllegalArgumentException("Travel application departmentId does not resolve to a valid DingTalk department ID");
+        }
+        return result;
+    }
+
     private List<Map<String, Object>> toItineraryRows(List<TravelApplicationPushRequest.Itinerary> itineraries) {
         List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
         if (itineraries == null) {
@@ -279,7 +1021,8 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
             return Long.valueOf(text);
         }
         try {
-            return LocalDate.parse(text.replace('/', '-')).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+            String dateText = text.length() >= 10 ? text.substring(0, 10) : text;
+            return LocalDate.parse(dateText.replace('/', '-')).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
         } catch (RuntimeException exception) {
             throw new IllegalArgumentException("Date must be yyyy-MM-dd or epoch milliseconds: " + value);
         }
@@ -317,6 +1060,14 @@ public class TravelApplicationPushServiceImpl implements TravelApplicationPushSe
         return isBlank(first) ? second : first;
     }
 
+    private String firstNonBlank(String first, String second) {
+        return isBlank(first) ? second : first;
+    }
+
+    private String firstNonBlank(String first, String second, String third) {
+        return !isBlank(first) ? first : (!isBlank(second) ? second : third);
+    }
+
     private boolean isBlank(Object value) {
         return value == null || String.valueOf(value).trim().isEmpty();
     }

+ 11 - 11
src/main/resources/application.yml

@@ -27,22 +27,22 @@ dingtalk:
 
 minglei:
   banking:
-    # 测试环境:支付、状态、流水、余额和支行查询均使用该地址;禁止改为正式回单地址。
-    base-url: ${MINGLEI_BANKING_BASE_URL:https://sandbox.nexbankapi.com}
-    # 正式环境:仅电子回单下载使用该地址,不会影响支付及其他查询接口。
-    receipt-base-url: ${MINGLEI_BANKING_RECEIPT_BASE_URL:https://open.nexbankapi.com}
+    # 正式环境:支付、支付状态、流水、余额、支行查询和电子回单下载均使用该地址。
+    base-url: ${MINGLEI_BANKING_BASE_URL:https://open.nexbankapi.com}
+    # 支付状态补偿任务:复用 employee-roster 数据源,表结构见 sql/banking_payment_tracking.sql。
+    pending-payment:
+      table-name: ${MINGLEI_BANKING_PENDING_PAYMENT_TABLE_NAME:banking_payment_tracking}
+      schedule-fixed-delay-millis: ${MINGLEI_BANKING_PENDING_PAYMENT_SCHEDULE_FIXED_DELAY_MILLIS:300000}
     payment-encryption-enabled: ${MINGLEI_BANKING_PAYMENT_ENCRYPTION_ENABLED:true}
-    app-id: ${MINGLEI_BANKING_APP_ID:f0241b326afe45fb852340120cf84302}
-    app-secret: ${MINGLEI_BANKING_APP_SECRET:D0cpE1DKtoV6O0X5cQOQO4quqo1wlOR6DMICXDXuV7zv}
-    customer-code: ${MINGLEI_BANKING_CUSTOMER_CODE:CUSTD74467BFC9EC8396}
-    default-from-account-no: ${MINGLEI_BANKING_DEFAULT_FROM_ACCOUNT_NO:123321}
-    default-from-account-name: ${MINGLEI_BANKING_DEFAULT_FROM_ACCOUNT_NAME:浙江明磊锂能源科技有限公司}
+    app-id: ${MINGLEI_BANKING_APP_ID:895565822f9544658035b3b14238d5b7}
+    app-secret: ${MINGLEI_BANKING_APP_SECRET:qb8cX3n1zwmJH5qDRQpBuRqE5imrJesLEkWJK5xTk0QY}
+    customer-code: ${MINGLEI_BANKING_CUSTOMER_CODE:CUST384D21B943304769}
     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}
+    xencio-public-key: ${MINGLEI_BANKING_XENCIO_PUBLIC_KEY:MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEcPG7quzrHQlrIuAV3WhMSvjvOZSyocmwTTAQkwazxMv5+KTbzFn29ZObRu1La90OOU3Q7HSsqrhkYK80VV/9Vg==}
+    enterprise-private-key: ${MINGLEI_BANKING_ENTERPRISE_PRIVATE_KEY:MIGTAgEAMBMGByqGSM49AgEGCCqBHM9VAYItBHkwdwIBAQQgffvSW7BkE9MSvfXIdEyAtSrecDzkECtwoaymGZjyQGugCgYIKoEcz1UBgi2hRANCAATjayFlLuOhGVerrwFTitrTbXUBd6URu2/PBJ4yMoGuwJ7vLliHkhSkp9xXnTpxF7T/MZUH3irBMALoRHXLaUFq}
     connect-timeout-millis: ${MINGLEI_BANKING_CONNECT_TIMEOUT_MILLIS:10000}
     read-timeout-millis: ${MINGLEI_BANKING_READ_TIMEOUT_MILLIS:30000}
   port:

+ 13 - 3
src/test/java/com/malk/minglei/config/BankingConfigurationTest.java

@@ -17,11 +17,21 @@ class BankingConfigurationTest {
      * 验证默认环境隔离:常规银企接口使用测试地址,回单下载使用正式地址。
      */
     @Test
-    void separatesSandboxApisFromProductionReceiptDownload() {
+    void usesProductionBaseUrlForAllBankingApis() {
         BankingProperties properties = new BankingProperties();
 
-        assertEquals("https://sandbox.nexbankapi.com", properties.getBaseUrl());
-        assertEquals("https://open.nexbankapi.com", properties.getReceiptBaseUrl());
+        assertEquals("https://open.nexbankapi.com", properties.getBaseUrl());
+    }
+
+    /**
+     * 验证支付状态补偿任务默认每五分钟运行一次。
+     */
+    @Test
+    void usesFiveMinutesAsDefaultPaymentTrackingSchedule() {
+        BankingPaymentTrackingProperties properties = new BankingPaymentTrackingProperties();
+
+        assertEquals(300000L, properties.getScheduleFixedDelayMillis());
+        assertEquals("banking_payment_tracking", properties.getTableName());
     }
 
     /**

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

@@ -26,6 +26,7 @@ class EmployeeRosterControllerTest {
     void createDingTalkDedicatedAccountReturnsCreatedEmployeeNo() throws Exception {
         EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
         DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setOaProcessInstanceId("oa-instance-1");
         request.setName("张三");
         request.setEmployeeNo("1001");
         request.setDepartmentList(Collections.<Object>singletonList(1L));

+ 3 - 6
src/test/java/com/malk/minglei/controller/TravelApplicationPushControllerTest.java

@@ -21,14 +21,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 class TravelApplicationPushControllerTest {
 
     /**
-     * 验证连接器传入的 OA 审批实例、人员和部门 ID 会被传递至服务层。
+     * 验证连接器仅传入 OA 审批实例 ID。
      */
     @Test
     void pushesTravelApplicationFromRequestBody() throws Exception {
         TravelApplicationPushService service = request -> {
             assertEquals("oa-process-001", request.getOaProcessInstanceId());
-            assertEquals("user-001", request.getUserId());
-            assertEquals("123", request.getDepartmentId());
             Map<String, Object> result = new LinkedHashMap<String, Object>();
             result.put("oaProcessInstanceId", request.getOaProcessInstanceId());
             result.put("yidaProcessInstanceId", "yida-process-001");
@@ -38,8 +36,7 @@ class TravelApplicationPushControllerTest {
 
         mockMvc.perform(post("/api/minglei/ports/travel-applications")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"oaProcessInstanceId\":\"oa-process-001\",\"userId\":\"user-001\","
-                                + "\"departmentId\":\"123\",\"title\":\"北京客户拜访\"}"))
+                        .content("{\"oaProcessInstanceId\":\"oa-process-001\"}"))
                 .andExpect(status().isCreated())
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.isSuccess").value(true))
@@ -56,7 +53,7 @@ class TravelApplicationPushControllerTest {
 
         mockMvc.perform(post("/api/minglei/ports/travel-applications")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"userId\":\"user-001\",\"departmentId\":\"123\",\"title\":\"北京客户拜访\"}"))
+                        .content("{}"))
                 .andExpect(status().isBadRequest());
     }
 }

+ 68 - 26
src/test/java/com/malk/minglei/service/impl/banking/BankingServiceImplTest.java

@@ -50,7 +50,6 @@ class BankingServiceImplTest {
         RestTemplate restTemplate = mock(RestTemplate.class);
         BankingProperties properties = new BankingProperties();
         properties.setBaseUrl("https://bank.example");
-        properties.setReceiptBaseUrl("https://bank.example");
         properties.setAppId("app-id");
         properties.setAppSecret("app-secret");
         properties.setCustomerCode("customer-code");
@@ -156,6 +155,7 @@ class BankingServiceImplTest {
         request.setBusinessTransNo("PAY-1");
         request.setPayAmount(new BigDecimal("1.00"));
         request.setFromAccountNo("from-account");
+        request.setFromAccountName("from-name");
         request.setToAccountNo("to-account");
         request.setToAccountType("0");
         request.setToBankName("交通银行");
@@ -195,6 +195,7 @@ class BankingServiceImplTest {
         request.setBusinessTransNo("PAY-FAILED");
         request.setPayAmount(new BigDecimal("1.00"));
         request.setFromAccountNo("from-account");
+        request.setFromAccountName("from-name");
         request.setToAccountNo("to-account");
         request.setToAccountType("0");
         request.setToBankName("交通银行");
@@ -221,17 +222,17 @@ class BankingServiceImplTest {
         BankingProperties properties = properties();
         properties.setPaymentEncryptionEnabled(false);
         properties.setReceiptFileStoragePath(temporaryDirectory.toString());
-        properties.setReceiptBaseUrl("https://receipt.example");
         BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper());
         stubBankBranchSearch(restTemplate);
         SinglePaymentRequest request = new SinglePaymentRequest();
         request.setBusinessTransNo("PAY-SUCCESS");
         request.setPayAmount(new BigDecimal("1.00"));
         request.setFromAccountNo("from-account");
+        request.setFromAccountName("from-name");
         request.setToAccountNo("to-account");
         request.setToBankName("交通银行");
         URI statusUri = URI.create("https://bank.example/api/v2/payment/transferStatus?businessTransNo=PAY-SUCCESS");
-        URI receiptUri = URI.create("https://receipt.example/api/v2/bs/ebill/download?businessTransNo=PAY-SUCCESS");
+        URI receiptUri = URI.create("https://bank.example/api/v2/bs/ebill/download?uniqueNumber=UNIQUE-SUCCESS");
         HttpHeaders receiptHeaders = new HttpHeaders();
         receiptHeaders.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=PAY-SUCCESS.pdf");
         receiptHeaders.setContentType(org.springframework.http.MediaType.APPLICATION_PDF);
@@ -240,7 +241,7 @@ class BankingServiceImplTest {
                 .willReturn(new ResponseEntity<String>("{\"code\":\"200\"}", HttpStatus.OK));
         given(restTemplate.exchange(eq(statusUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
                 .willReturn(new ResponseEntity<String>(
-                        "{\"code\":\"200\",\"data\":{\"transferStatus\":\"X0000\"}}", HttpStatus.OK));
+                        "{\"code\":\"200\",\"data\":{\"transferStatus\":\"X0000\",\"uniqueNumber\":\"UNIQUE-SUCCESS\"}}", HttpStatus.OK));
         given(restTemplate.exchange(eq(receiptUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class)))
                 .willReturn(new ResponseEntity<byte[]>("receipt".getBytes(java.nio.charset.StandardCharsets.UTF_8),
                         receiptHeaders, HttpStatus.OK));
@@ -269,7 +270,7 @@ class BankingServiceImplTest {
         headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=receipt.pdf");
         headers.setContentType(org.springframework.http.MediaType.APPLICATION_PDF);
         URI expectedUri = URI.create("https://bank.example/api/v2/bs/ebill/download?uniqueNumber=UNIQUE-1");
-        byte[] fileContent = "receipt-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        byte[] fileContent = "%PDF-1.7 receipt-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
         given(restTemplate.exchange(eq(expectedUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class)))
                 .willReturn(new ResponseEntity<byte[]>(fileContent, headers, HttpStatus.OK));
 
@@ -279,27 +280,52 @@ class BankingServiceImplTest {
         assertEquals(fileContent.length, result.getFileSize());
         assertEquals("application/pdf", result.getContentType());
         assertTrue(Files.exists(Paths.get(result.getFilePath())));
-        assertEquals("receipt-content", new String(Files.readAllBytes(Paths.get(result.getFilePath())),
+        assertEquals("%PDF-1.7 receipt-content", new String(Files.readAllBytes(Paths.get(result.getFilePath())),
                 java.nio.charset.StandardCharsets.UTF_8));
         verify(restTemplate).exchange(eq(expectedUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class));
     }
 
+    /**
+     * 验证上游回单文件名包含空白时,保存文件名会转换为 URL 安全的名称。
+     */
+    @Test
+    void normalizesReceiptFileNameWithSpaces() throws Exception {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        BankingProperties properties = properties();
+        properties.setReceiptFileStoragePath(temporaryDirectory.toString());
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper());
+        ElectronicBillDownloadRequest request = new ElectronicBillDownloadRequest();
+        request.setUniqueNumber("UNIQUE-SPACE");
+        HttpHeaders headers = new HttpHeaders();
+        headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"receipt with space.pdf\"");
+        headers.setContentType(org.springframework.http.MediaType.APPLICATION_PDF);
+        URI expectedUri = URI.create("https://bank.example/api/v2/bs/ebill/download?uniqueNumber=UNIQUE-SPACE");
+        byte[] fileContent = "%PDF-1.7 receipt-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        given(restTemplate.exchange(eq(expectedUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class)))
+                .willReturn(new ResponseEntity<byte[]>(fileContent, headers, HttpStatus.OK));
+
+        ElectronicBillDownloadResult result = service.downloadElectronicBill(request);
+
+        assertEquals("receipt_with_space.pdf", result.getFileName());
+        assertTrue(Files.exists(Paths.get(result.getFilePath())));
+    }
+
     /**
      * 验证两个回单查询条件同时缺失或同时传入时,不会调用上游接口。
      */
     @Test
-    void rejectsInvalidElectronicBillIdentifiersBeforeCallingUpstream() {
+    void rejectsBothReceiptQueryConditions() {
         RestTemplate restTemplate = mock(RestTemplate.class);
-        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties(), new ObjectMapper());
+        BankingProperties properties = properties();
+        properties.setReceiptFileStoragePath(temporaryDirectory.toString());
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper());
         ElectronicBillDownloadRequest request = new ElectronicBillDownloadRequest();
         request.setUniqueNumber("UNIQUE-1");
         request.setBusinessTransNo("PAY-1");
 
-        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
-                () -> service.downloadElectronicBill(request));
-
-        assertEquals("uniqueNumber 与 businessTransNo 必须且只能传入一个", exception.getMessage());
-        verify(restTemplate, never()).exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class));
+        assertThrows(IllegalArgumentException.class, () -> service.downloadElectronicBill(request));
+        verify(restTemplate, never()).exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class),
+                eq(byte[].class));
     }
 
     /**
@@ -310,8 +336,6 @@ class BankingServiceImplTest {
         RestTemplate restTemplate = mock(RestTemplate.class);
         BankingProperties properties = properties();
         properties.setPaymentEncryptionEnabled(false);
-        properties.setDefaultFromAccountNo("configured-account");
-        properties.setDefaultFromAccountName("configured-name");
         BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper());
         stubBankBranchSearch(restTemplate);
         SinglePaymentRequest request = new SinglePaymentRequest();
@@ -319,21 +343,39 @@ class BankingServiceImplTest {
         request.setPayAmount(new BigDecimal("1.00"));
         request.setToAccountNo("to-account");
         request.setToBankName("交通银行");
+        IllegalStateException exception = assertThrows(IllegalStateException.class,
+                () -> service.submitSinglePayment(request));
+        assertEquals("付款账号不能为空,必须由接口传入", exception.getMessage());
+        verify(restTemplate, never()).exchange(any(String.class), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class));
+    }
+
+    /**
+     * 验证制单成功后会关联保存宜搭表单标识,供异步支付状态补偿任务继续处理。
+     */
+    @Test
+    void savesSuccessfulPaymentForAsynchronousStatusTracking() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        BankingProperties properties = properties();
+        properties.setPaymentEncryptionEnabled(false);
+        BankingPaymentTrackingRepository trackingRepository = mock(BankingPaymentTrackingRepository.class);
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper(), trackingRepository);
+        stubBankBranchSearch(restTemplate);
+        SinglePaymentRequest request = new SinglePaymentRequest();
+        request.setBusinessTransNo("PAY-TRACKING");
+        request.setPayAmount(new BigDecimal("1.00"));
+        request.setFromAccountNo("from-account");
+        request.setFromAccountName("from-name");
+        request.setToAccountNo("to-account");
+        request.setToBankName("交通银行");
+        request.setFormID("FORM-1");
+        request.setFormInstanceId("FORM-INSTANCE-1");
         given(restTemplate.exchange(eq("https://bank.example/api/v2/payment/commonPay"), eq(HttpMethod.POST),
                 any(HttpEntity.class), eq(String.class)))
                 .willReturn(new ResponseEntity<String>("{\"code\":\"200\"}", HttpStatus.OK));
 
         service.submitSinglePayment(request);
 
-        ArgumentCaptor<HttpEntity> entityCaptor = ArgumentCaptor.forClass(HttpEntity.class);
-        verify(restTemplate).exchange(eq("https://bank.example/api/v2/payment/commonPay"), eq(HttpMethod.POST),
-                entityCaptor.capture(), eq(String.class));
-        Map<String, Object> body = (Map<String, Object>) entityCaptor.getValue().getBody();
-        assertEquals("configured-account", body.get("fromAccountNo"));
-        assertEquals("configured-name", body.get("fromAccountName"));
-        assertEquals("0", body.get("crossBank"));
-        assertEquals("0", body.get("toAccountType"));
-        assertEquals("301", body.get("toBankNo"));
+        verify(trackingRepository).saveSubmittedPayment(eq(request));
     }
 
     /**
@@ -344,13 +386,14 @@ class BankingServiceImplTest {
         RestTemplate restTemplate = mock(RestTemplate.class);
         BankingProperties properties = properties();
         properties.setPaymentEncryptionEnabled(false);
-        properties.setDefaultFromAccountNo("configured-account");
         properties.setIntraBankName("交通银行");
         BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper());
         stubBankBranchSearch(restTemplate);
         SinglePaymentRequest request = new SinglePaymentRequest();
         request.setBusinessTransNo("PAY-3");
         request.setPayAmount(new BigDecimal("1.00"));
+        request.setFromAccountNo("from-account");
+        request.setFromAccountName("from-name");
         request.setToAccountNo("to-account");
         request.setToAccountType("0");
         request.setToBankName("中国工商银行");
@@ -380,7 +423,6 @@ class BankingServiceImplTest {
     private BankingProperties properties() {
         BankingProperties properties = new BankingProperties();
         properties.setBaseUrl("https://bank.example");
-        properties.setReceiptBaseUrl("https://bank.example");
         properties.setAppId("app-id");
         properties.setAppSecret("app-secret");
         properties.setCustomerCode("customer-code");

+ 15 - 7
src/test/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImplTest.java

@@ -1,5 +1,7 @@
 package com.malk.minglei.service.impl.dictionary;
 
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.service.employee.EmployeeRosterService;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import org.springframework.beans.factory.ObjectProvider;
@@ -300,37 +302,43 @@ class DictionaryQueryServiceImplTest {
     }
 
     /**
-     * 验证生产令开窗按据点和逗号分隔的拥有者权限查询,并固定返回前 100 条记录。
+     * 验证生产令开窗按据点和用户所属部门查询,并固定返回前 100 条记录。
      */
     @Test
-    void queryErpProductionOrderUsesSiteAndOwnerPermissions() {
+    void queryErpProductionOrderConvertsUserIdToEmployeeNo() {
         ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
         NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
         given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
         given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
                 .willReturn(Long.valueOf(8L));
         given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
                 .willReturn(Collections.<Map<String, Object>>emptyList());
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setEmployeeNo("EMP-001");
+        given(employeeRosterService.findByUserId("USER-001")).willReturn(employee);
 
-        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider, employeeRosterService);
         Map<String, String> filters = new LinkedHashMap<String, String>();
         filters.put("t100id", "ML");
-        filters.put("qx", "OWNER-A, OWNER-B");
+        filters.put("userId", "USER-001");
 
         Map<String, Object> result = service.query("erp-production-order", filters);
 
         ArgumentCaptor<String> countSql = ArgumentCaptor.forClass(String.class);
         ArgumentCaptor<SqlParameterSource> countParameters = ArgumentCaptor.forClass(SqlParameterSource.class);
         verify(jdbcTemplate).queryForObject(countSql.capture(), countParameters.capture(), eq(Long.class));
-        assertEquals(true, countSql.getValue().contains("and xmdaownid in (:qx)"));
+        assertEquals(true, countSql.getValue().contains("and xmdaowndp = (select ooag003"));
         assertEquals("ML", countParameters.getValue().getValue("t100id"));
-        assertEquals(Arrays.asList("OWNER-A", "OWNER-B"), countParameters.getValue().getValue("qx"));
+        assertEquals("EMP-001", countParameters.getValue().getValue("employeeNo"));
 
         ArgumentCaptor<String> pageSql = ArgumentCaptor.forClass(String.class);
         ArgumentCaptor<SqlParameterSource> pageParameters = ArgumentCaptor.forClass(SqlParameterSource.class);
         verify(jdbcTemplate).queryForList(pageSql.capture(), pageParameters.capture());
         assertEquals(true, pageSql.getValue().contains("select distinct xmdasite, xmdadocno"));
-        assertEquals(true, pageSql.getValue().contains("and xmdastus = 'Y' and xmdaownid in (:qx)"));
+        assertEquals(true, pageSql.getValue().contains("and ooag001 = :employeeNo"));
+        assertEquals("EMP-001", pageParameters.getValue().getValue("employeeNo"));
+        verify(employeeRosterService).findByUserId("USER-001");
         assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
         assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
         assertEquals(1, result.get("page"));

+ 65 - 20
src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java

@@ -6,6 +6,7 @@ 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.approval.DingTalkApprovalClient;
 import com.malk.minglei.service.employee.DingTalkEmployeeRosterClient;
 import com.malk.server.dingtalk.DDConf;
 import com.malk.service.dingtalk.DDClient;
@@ -40,13 +41,18 @@ class EmployeeRosterServiceImplTest {
     void createDingTalkDedicatedAccountUsesProvidedEmployeeNoForLoginCredentials() {
         DDClient dingTalkClient = mock(DDClient.class);
         DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.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("张三"),
+        given(approvalClient.getProcessInstanceDetail("access-token", "oa-instance-1"))
+                .willReturn(dedicatedAccountApproval("张三", "1001", mapOf("id", "1"), "研发工程师", "男",
+                        "28", "2026-11-01", "高级工程师", "P6", "TEST-ID-001", "软件工程师"));
+        given(contactsClient.createUser_dingTalk(eq("access-token"), eq("1001"), eq("ML1001"), eq("张三"),
                 anyList(), anyMap())).willReturn(mapOf("userid", "ding-user-1"));
         given(repository.upsertAll(anyList())).willReturn(1);
 
         DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setOaProcessInstanceId("oa-instance-1");
         request.setName("张三");
         request.setEmployeeNo("1001");
         request.setPositionName("研发工程师");
@@ -68,25 +74,27 @@ class EmployeeRosterServiceImplTest {
                 mock(DingTalkEmployeeRosterClient.class),
                 repository,
                 new EmployeeRosterProperties(),
-                mock(EmployeeRosterDuplicateNameNotifier.class));
+                mock(EmployeeRosterDuplicateNameNotifier.class),
+                approvalClient);
 
         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("张三"),
+        verify(contactsClient).createUser_dingTalk(eq("access-token"), eq("1001"), eq("ML1001"), 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(Collections.singletonList(1L), departmentIdsCaptor.getValue());
         assertEquals("1001", extraFieldsCaptor.getValue().get("job_number"));
-        assertEquals("高级工程师", extraFieldsCaptor.getValue().get("title"));
+        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("高级工程师", extension.get("jobTitle"));
         assertEquals(Integer.valueOf(28), extension.get("age"));
         assertEquals("P6", extension.get("jobGrade"));
         assertEquals("TEST-ID-001", extension.get("idCardNo"));
@@ -100,7 +108,7 @@ class EmployeeRosterServiceImplTest {
         assertEquals("P6", savedRecord.getJobGrade());
         assertEquals("ding-user-1", result.getUserId());
         assertEquals("1001", result.getEmployeeNo());
-        assertEquals(Arrays.asList(1L, 2L, 3L), result.getDepartmentIds());
+        assertEquals(Collections.singletonList(1L), result.getDepartmentIds());
     }
 
     @Test
@@ -108,14 +116,19 @@ class EmployeeRosterServiceImplTest {
     void createDingTalkDedicatedAccountGeneratesEmployeeNoWhenItIsNotProvided() {
         DDClient dingTalkClient = mock(DDClient.class);
         DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
         EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
         given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(approvalClient.getProcessInstanceDetail("access-token", "oa-instance-2"))
+                .willReturn(dedicatedAccountApproval("李四", null, mapOf("id", "1"), null, null,
+                        null, null, null, null, null, null));
         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("李四"),
+        given(contactsClient.createUser_dingTalk(eq("access-token"), eq("2610260001"), eq("ML2610260001"), eq("李四"),
                 anyList(), anyMap())).willReturn(mapOf("userid", "ding-user-2"));
 
         DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setOaProcessInstanceId("oa-instance-2");
         request.setName("李四");
         request.setDepartmentList(Collections.<Object>singletonList(1L));
 
@@ -126,12 +139,13 @@ class EmployeeRosterServiceImplTest {
                 mock(DingTalkEmployeeRosterClient.class),
                 repository,
                 new EmployeeRosterProperties(),
-                mock(EmployeeRosterDuplicateNameNotifier.class));
+                mock(EmployeeRosterDuplicateNameNotifier.class),
+                approvalClient);
 
         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("李四"),
+        verify(contactsClient).createUser_dingTalk(eq("access-token"), eq("2610260001"), eq("ML2610260001"), eq("李四"),
                 anyList(), anyMap());
         assertEquals("2610260001", result.getEmployeeNo());
     }
@@ -141,17 +155,22 @@ class EmployeeRosterServiceImplTest {
     void createDingTalkDedicatedAccountResolvesDepartmentNameToDepartmentId() {
         DDClient dingTalkClient = mock(DDClient.class);
         DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
         EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
         given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(approvalClient.getProcessInstanceDetail("access-token", "oa-instance-3"))
+                .willReturn(dedicatedAccountApproval("王五", "1002", "集团运营中心-人力资源部", null, null,
+                        null, null, null, null, null, null));
         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("王五"),
+        given(contactsClient.createUser_dingTalk(eq("access-token"), eq("1002"), eq("ML1002"), eq("王五"),
                 anyList(), anyMap())).willReturn(mapOf("userid", "ding-user-3"));
         given(repository.upsertAll(anyList())).willReturn(1);
 
         DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
+        request.setOaProcessInstanceId("oa-instance-3");
         request.setName("王五");
         request.setEmployeeNo("1002");
         request.setDepartmentList(Collections.<Object>singletonList("集团运营中心-人力资源部"));
@@ -163,7 +182,8 @@ class EmployeeRosterServiceImplTest {
                 mock(DingTalkEmployeeRosterClient.class),
                 repository,
                 new EmployeeRosterProperties(),
-                mock(EmployeeRosterDuplicateNameNotifier.class));
+                mock(EmployeeRosterDuplicateNameNotifier.class),
+                approvalClient);
 
         service.createDingTalkDedicatedAccount(request);
 
@@ -171,7 +191,7 @@ class EmployeeRosterServiceImplTest {
         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("王五"),
+        verify(contactsClient).createUser_dingTalk(eq("access-token"), eq("1002"), eq("ML1002"), eq("王五"),
                 departmentIdsCaptor.capture(), anyMap());
         assertEquals(Collections.singletonList(20L), departmentIdsCaptor.getValue());
     }
@@ -183,9 +203,6 @@ class EmployeeRosterServiceImplTest {
         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,
@@ -197,7 +214,7 @@ class EmployeeRosterServiceImplTest {
 
         IllegalArgumentException exception = org.junit.jupiter.api.Assertions.assertThrows(
                 IllegalArgumentException.class,
-                () -> service.createDingTalkDedicatedAccount(request));
+                () -> service.resolveDingTalkDepartmentIds(Collections.<Object>singletonList("不存在的部门")));
 
         assertEquals("departmentList contains an unknown DingTalk department path: 不存在的部门", exception.getMessage());
     }
@@ -211,9 +228,6 @@ class EmployeeRosterServiceImplTest {
                 department(10L, "研发部", null),
                 department(20L, "研发部", null)));
 
-        DingTalkDedicatedAccountCreateRequest request = new DingTalkDedicatedAccountCreateRequest();
-        request.setDepartmentList(Collections.<Object>singletonList("研发部"));
-
         EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
                 dingTalkClient,
                 contactsClient,
@@ -225,7 +239,7 @@ class EmployeeRosterServiceImplTest {
 
         IllegalArgumentException exception = org.junit.jupiter.api.Assertions.assertThrows(
                 IllegalArgumentException.class,
-                () -> service.createDingTalkDedicatedAccount(request));
+                () -> service.resolveDingTalkDepartmentIds(Collections.<Object>singletonList("研发部")));
 
         assertEquals("departmentList matches multiple DingTalk departments for name: 研发部", exception.getMessage());
     }
@@ -429,6 +443,37 @@ class EmployeeRosterServiceImplTest {
         return map;
     }
 
+    private static Map<String, Object> dedicatedAccountApproval(String name, String employeeNo, Object department,
+                                                                 String positionName, String gender, String age,
+                                                                 String expectedArrivalDate, String jobTitle,
+                                                                 String jobGrade, String idCardNo, String position) {
+        List<Map<String, Object>> components = Arrays.asList(
+                approvalComponent("姓名", name),
+                approvalComponent("部门", null, department),
+                approvalComponent("工号", employeeNo),
+                approvalComponent("岗位", positionName),
+                approvalComponent("职位", position),
+                approvalComponent("性别", gender),
+                approvalComponent("年龄", age),
+                approvalComponent("预计到岗日期", expectedArrivalDate),
+                approvalComponent("职称", jobTitle),
+                approvalComponent("职等", jobGrade),
+                approvalComponent("身份证号", idCardNo));
+        return mapOf("success", Boolean.TRUE, "result", mapOf("formComponentValues", components));
+    }
+
+    private static Map<String, Object> approvalComponent(String name, Object value) {
+        return approvalComponent(name, value, null);
+    }
+
+    private static Map<String, Object> approvalComponent(String name, Object value, Object extValue) {
+        Map<String, Object> component = new LinkedHashMap<String, Object>();
+        component.put("name", name);
+        component.put("value", value);
+        component.put("extValue", extValue);
+        return component;
+    }
+
     private static Map department(Long id, String name, Long parentId) {
         Map<String, Object> department = new LinkedHashMap<String, Object>();
         department.put("dept_id", id);

+ 228 - 35
src/test/java/com/malk/minglei/service/impl/port/TravelApplicationPushServiceImplTest.java

@@ -4,9 +4,13 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.malk.minglei.config.YidaApplicationsProperties;
 import com.malk.minglei.dto.TravelApplicationPushRequest;
+import com.malk.minglei.service.approval.DingTalkApprovalClient;
+import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
 import com.malk.server.dingtalk.DDConf;
 import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 
 import java.math.BigDecimal;
@@ -16,6 +20,9 @@ import java.util.List;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 
 /**
  * 出差申请 OA 推送服务测试。
@@ -23,14 +30,26 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 class TravelApplicationPushServiceImplTest {
 
     /**
-     * 验证 OA 主表和三张子表会转换为对应的宜搭字段,并使用流程所属的协同办公应用凭据发起流程。
+     * 验证服务端从 OA 审批实例详情读取主表和三张子表,并使用协同办公应用凭据发起流程。
      */
     @Test
     void mapsOaTravelApplicationToYidaProcess() {
         CapturingYidaClient client = new CapturingYidaClient();
-        TravelApplicationPushServiceImpl service = new TravelApplicationPushServiceImpl(client, dingTalkConfiguration(), yidaApplications());
+        DDClient dingTalkClient = mock(DDClient.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        given(approvalClient.getProcessInstanceDetail("access-token", "oa-process-001"))
+                .willReturn(approvalDetail());
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        given(employeeRosterService.resolveDingTalkDepartmentIds(java.util.Collections.<Object>singletonList("123")))
+                .willReturn(java.util.Collections.singletonList(Long.valueOf(123L)));
+        TravelApplicationPushServiceImpl service = new TravelApplicationPushServiceImpl(
+                client, dingTalkClient, approvalClient, dingTalkConfiguration(), yidaApplications(),
+                employeeRosterService, new ObjectMapper());
 
-        Map<String, Object> result = service.push(createRequest());
+        TravelApplicationPushRequest request = new TravelApplicationPushRequest();
+        request.setOaProcessInstanceId("oa-process-001");
+        Map<String, Object> result = service.push(request);
 
         assertEquals("oa-process-001", result.get("oaProcessInstanceId"));
         assertEquals("yida-process-001", result.get("yidaProcessInstanceId"));
@@ -42,47 +61,221 @@ class TravelApplicationPushServiceImplTest {
         assertEquals("北京客户拜访", formData.getString("textField_msh27unu"));
         assertEquals("user-001", formData.getJSONArray("employeeField_msh186rr").getString(0));
         assertEquals("123", formData.getJSONArray("departmentSelectField_msh186ry").getString(0));
+        verify(employeeRosterService).resolveDingTalkDepartmentIds(
+                java.util.Collections.<Object>singletonList("123"));
         assertEquals("北京", formData.getJSONArray("tableField_mvorr6o1").getJSONObject(0)
                 .getString("selectField_mvorr6nv"));
+        assertEquals("厦门", formData.getJSONArray("tableField_mvorr6o1").getJSONObject(0)
+                .getString("selectField_mvorr6nu"));
+        assertEquals("上午", formData.getJSONArray("tableField_mvorr6o1").getJSONObject(0)
+                .getString("selectField_mvorr6ny"));
+        assertEquals(new BigDecimal("0.5"), formData.getJSONArray("tableField_mvorr6o1").getJSONObject(0)
+                .getBigDecimal("numberField_mvorr6o0"));
+        assertEquals("是", formData.getJSONArray("tableField_mvorr6oe").getJSONObject(0)
+                .getString("selectField_mvorr6of"));
         assertEquals(new BigDecimal("5600"), formData.getJSONArray("tableField_mvorr6oe").getJSONObject(0)
                 .getBigDecimal("numberField_mvorr6oj"));
         assertEquals("北京", formData.getJSONArray("tableField_mvorr6oo").getJSONObject(0)
                 .getString("selectField_mvorr6op"));
+        verify(approvalClient).getProcessInstanceDetail("access-token", "oa-process-001");
     }
 
-    private TravelApplicationPushRequest createRequest() {
+    @Test
+    void mapsDingTalkRowValueAndEmployeeExtensionIds() {
+        CapturingYidaClient client = new CapturingYidaClient();
+        DDClient dingTalkClient = mock(DDClient.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        given(approvalClient.getProcessInstanceDetail("access-token", "oa-process-002"))
+                .willReturn(approvalDetailWithRowValue());
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        given(employeeRosterService.resolveDingTalkDepartmentIds(java.util.Collections.<Object>singletonList("123")))
+                .willReturn(java.util.Collections.singletonList(Long.valueOf(123L)));
+        TravelApplicationPushServiceImpl service = new TravelApplicationPushServiceImpl(
+                client, dingTalkClient, approvalClient, dingTalkConfiguration(), yidaApplications(),
+                employeeRosterService, new ObjectMapper());
+
         TravelApplicationPushRequest request = new TravelApplicationPushRequest();
-        request.setOaProcessInstanceId("oa-process-001");
-        request.setUserId("user-001");
-        request.setDepartmentId("123");
-        request.setTitle("北京客户拜访");
-        request.setApplyDate("2026-10-26");
-        request.setActualTravelerUserIds(singleton("user-001"));
-
-        TravelApplicationPushRequest.Itinerary itinerary = new TravelApplicationPushRequest.Itinerary();
-        itinerary.setDepartureCity("北京");
-        itinerary.setDestinationCity("上海");
-        itinerary.setStartDate("2026-10-27");
-        itinerary.setEndDate("2026-10-29");
-        itinerary.setTravelDays(new BigDecimal("3"));
-        request.setItineraries(singleton(itinerary));
-
-        TravelApplicationPushRequest.Flight flight = new TravelApplicationPushRequest.Flight();
-        flight.setTicketReserved("是");
-        flight.setTravelRegion("国内");
-        flight.setTicketType("往返");
-        flight.setTripCount(1);
-        flight.setBudgetAmount(new BigDecimal("5600"));
-        flight.setOverStandard("否");
-        request.setFlights(singleton(flight));
-
-        TravelApplicationPushRequest.Budget budget = new TravelApplicationPushRequest.Budget();
-        budget.setDepartureLocation("北京");
-        budget.setArrivalLocation("上海");
-        budget.setAccommodationDays(new BigDecimal("2"));
-        budget.setTotalCost(new BigDecimal("7600"));
-        request.setBudgets(singleton(budget));
-        return request;
+        request.setOaProcessInstanceId("oa-process-002");
+        service.push(request);
+
+        JSONObject formData = JSON.parseObject(String.valueOf(client.body.get("formDataJson")));
+        assertEquals("companion-001", formData.getJSONArray("employeeField_mvorr6nc").getString(0));
+        assertEquals("飞机", formData.getString("selectField_mvorr6nk"));
+        assertEquals("往返", formData.getString("selectField_mvorr6nl"));
+        assertEquals("杭州", formData.getJSONArray("tableField_mvorr6o1").getJSONObject(0)
+                .getString("selectField_mvorr6nv"));
+        assertEquals("宁波", formData.getJSONArray("tableField_mvorr6o1").getJSONObject(0)
+                .getString("selectField_mvorr6nu"));
+        assertEquals("下午", formData.getJSONArray("tableField_mvorr6o1").getJSONObject(0)
+                .getString("selectField_mvorr6ny"));
+        assertEquals("东南亚", formData.getJSONArray("tableField_mvorr6oe").getJSONObject(0)
+                .getString("selectField_mvorr6og"));
+        assertEquals(new BigDecimal("2"), formData.getJSONArray("tableField_mvorr6oe").getJSONObject(0)
+                .getBigDecimal("numberField_mvorr6oi"));
+        assertEquals(new BigDecimal("300"), formData.getJSONArray("tableField_mvorr6oo").getJSONObject(0)
+                .getBigDecimal("numberField_mvorr6os"));
+    }
+
+    /**
+     * 验证钉钉真实返回的 value(JSON 字符串) -> rowValue -> bizAlias/label/value 结构。
+     * 外层控件标题不稳定时,也应能根据行内字段识别出行程子表。
+     */
+    @Test
+    void mapsNestedRowValueWithBizAliases() {
+        CapturingYidaClient client = new CapturingYidaClient();
+        DDClient dingTalkClient = mock(DDClient.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        given(approvalClient.getProcessInstanceDetail("access-token", "oa-process-003"))
+                .willReturn(approvalDetailWithBizAliases());
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        given(employeeRosterService.resolveDingTalkDepartmentIds(java.util.Collections.<Object>singletonList("123")))
+                .willReturn(java.util.Collections.singletonList(Long.valueOf(123L)));
+        TravelApplicationPushServiceImpl service = new TravelApplicationPushServiceImpl(
+                client, dingTalkClient, approvalClient, dingTalkConfiguration(), yidaApplications(),
+                employeeRosterService, new ObjectMapper());
+
+        TravelApplicationPushRequest request = new TravelApplicationPushRequest();
+        request.setOaProcessInstanceId("oa-process-003");
+        service.push(request);
+
+        JSONObject formData = JSON.parseObject(String.valueOf(client.body.get("formDataJson")));
+        assertEquals("飞机", formData.getString("selectField_mvorr6nk"));
+        assertEquals("单程", formData.getString("selectField_mvorr6nl"));
+        JSONObject itinerary = formData.getJSONArray("tableField_mvorr6o1").getJSONObject(0);
+        assertEquals("北京", itinerary.getString("selectField_mvorr6nv"));
+        assertEquals("厦门", itinerary.getString("selectField_mvorr6nu"));
+        assertEquals("上午", itinerary.getString("selectField_mvorr6ny"));
+        assertEquals(new BigDecimal("0.5"), itinerary.getBigDecimal("numberField_mvorr6o0"));
+    }
+
+    private Map<String, Object> approvalDetail() {
+        Map<String, Object> detail = new LinkedHashMap<String, Object>();
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("originatorUserId", "user-001");
+        result.put("title", "北京客户拜访");
+        List<Map<String, Object>> components = new ArrayList<Map<String, Object>>();
+        components.add(component("申请部门", "集团运营中心-人力资源部", "[{\"id\":123}]"));
+        components.add(component("申请日期", "2026-10-26", null));
+        components.add(component("实际出差人", "", "[{\"userid\":\"user-001\"}]"));
+        components.add(component("出发地", "北京", null));
+        components.add(component("目的地", "厦门", null));
+        components.add(component("开始时间", "2026-10-27 上午", null));
+        components.add(component("结束时间", "2026-10-27 下午", null));
+        components.add(component("时长(天)", "0.5", null));
+        components.add(component("出差天数", "1", null));
+        components.add(component("出行人(同行人)", "", "[{\"userid\":\"user-002\"}]"));
+        components.add(component("是否预定机票", "是", null));
+        components.add(tableComponent("飞机航程", flightRows()));
+        components.add(tableComponent("费用预算", budgetRows()));
+        result.put("formComponentValues", components);
+        detail.put("success", true);
+        detail.put("result", result);
+        return detail;
+    }
+
+    private Map<String, Object> approvalDetailWithRowValue() {
+        Map<String, Object> detail = new LinkedHashMap<String, Object>();
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("originatorUserId", "user-001");
+        result.put("title", "东南亚客户拜访");
+        List<Map<String, Object>> components = new ArrayList<Map<String, Object>>();
+        components.add(component("申请部门", "集团运营中心-人力资源部", "[{\"id\":123}]"));
+        components.add(component("申请日期", "2026-10-28", null));
+        components.add(component("实际出差人", "", "[{\"emplId\":\"companion-001\",\"name\":\"同行人\"}]"));
+        components.add(tableComponent("行程", rowValueRows(labeledComponent("交通工具", "飞机", null),
+                labeledComponent("单程往返", "往返", null), labeledComponent("出发城市", "杭州", null),
+                labeledComponent("目的城市", "宁波", null), labeledComponent("开始时间", "2026-10-28 下午", null),
+                labeledComponent("结束时间", "2026-10-29 上午", null), labeledComponent("时长(天)", "1", null),
+                labeledComponent("出行人(同行人)", "", "[{\"emplId\":\"companion-001\"}]"))));
+        components.add(tableComponent("飞机航程", rowValueRows(labeledComponent("出差区域类型", "东南亚", null),
+                labeledComponent("机票类型", "往返", null), labeledComponent("次数", "2", null),
+                labeledComponent("预算金额", "1000", null))));
+        components.add(tableComponent("费用预算", rowValueRows(labeledComponent("出发地点", "杭州", null),
+                labeledComponent("到达地点", "宁波", null), labeledComponent("交通费用(除飞机外)", "300", null))));
+        result.put("formComponentValues", components);
+        detail.put("success", true);
+        detail.put("result", result);
+        return detail;
+    }
+
+    private Map<String, Object> approvalDetailWithBizAliases() {
+        Map<String, Object> detail = new LinkedHashMap<String, Object>();
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("originatorUserId", "user-001");
+        result.put("title", "北京客户拜访");
+        List<Map<String, Object>> components = new ArrayList<Map<String, Object>>();
+        components.add(component("申请部门", "集团运营中心-人力资源部", "[{\"id\":123}]"));
+        components.add(component("申请日期", "2026-10-28", null));
+        List<Map<String, Object>> rowFields = new ArrayList<Map<String, Object>>();
+        rowFields.add(bizAliasComponent("交通工具", "vehicle", "飞机"));
+        rowFields.add(bizAliasComponent("单程往返", "singleOrReturn", "单程"));
+        rowFields.add(bizAliasComponent("出发城市", "departure", "北京"));
+        rowFields.add(bizAliasComponent("目的城市", "arrival", "厦门"));
+        rowFields.add(bizAliasComponent("开始时间", "startTime", "2026-10-28 上午"));
+        rowFields.add(bizAliasComponent("结束时间", "endTime", "2026-10-28 上午"));
+        rowFields.add(bizAliasComponent("时长", "duration", Double.valueOf(0.5)));
+        Map<String, Object> row = new LinkedHashMap<String, Object>();
+        row.put("rowValue", rowFields);
+        // 真实返回中外层标题为“日程”,value 仍是 JSON 字符串。
+        components.add(component("日程", JSON.toJSONString(java.util.Collections.singletonList(row)),
+                "{\"statValue\":[],\"componentName\":\"TableField\"}"));
+        result.put("formComponentValues", components);
+        detail.put("success", true);
+        detail.put("result", result);
+        return detail;
+    }
+
+    private Map<String, Object> component(String name, String value, String extValue) {
+        Map<String, Object> component = new LinkedHashMap<String, Object>();
+        component.put("name", name);
+        component.put("value", value);
+        component.put("extValue", extValue);
+        return component;
+    }
+
+    private Map<String, Object> labeledComponent(String label, String value, String extValue) {
+        Map<String, Object> component = new LinkedHashMap<String, Object>();
+        component.put("label", label);
+        component.put("value", value);
+        component.put("extValue", extValue);
+        return component;
+    }
+
+    private Map<String, Object> bizAliasComponent(String label, String bizAlias, Object value) {
+        Map<String, Object> component = new LinkedHashMap<String, Object>();
+        component.put("bizAlias", bizAlias);
+        component.put("label", label);
+        component.put("value", value);
+        return component;
+    }
+
+    private Map<String, Object> tableComponent(String name, List<Map<String, Object>> rows) {
+        return component(name, JSON.toJSONString(rows), null);
+    }
+
+    private List<Map<String, Object>> flightRows() {
+        return singleton(row(labeledComponent("出差区域类型", "国内", null), labeledComponent("机票类型", "往返", null),
+                labeledComponent("次数", "1", null), labeledComponent("预算金额", "5600", null),
+                labeledComponent("是否超标", "否", null)));
+    }
+
+    private List<Map<String, Object>> budgetRows() {
+        return singleton(row(labeledComponent("出发地点", "北京", null), labeledComponent("到达地点", "上海", null),
+                labeledComponent("住宿天数", "2", null), labeledComponent("费用合计", "7600", null)));
+    }
+
+    private Map<String, Object> row(Map<String, Object>... components) {
+        Map<String, Object> row = new LinkedHashMap<String, Object>();
+        row.put("componentValues", java.util.Arrays.asList(components));
+        return row;
+    }
+
+    private List<Map<String, Object>> rowValueRows(Map<String, Object>... components) {
+        Map<String, Object> row = new LinkedHashMap<String, Object>();
+        row.put("rowValue", java.util.Arrays.asList(components));
+        return singleton(row);
     }
 
     private DDConf dingTalkConfiguration() {