ht před 1 týdnem
rodič
revize
f402ec817b
36 změnil soubory, kde provedl 5002 přidání a 0 odebrání
  1. 46 0
      src/main/java/com/malk/minglei/controller/ExpenseReimbursementController.java
  2. 45 0
      src/main/java/com/malk/minglei/controller/TravelApplicationPushController.java
  3. 46 0
      src/main/java/com/malk/minglei/controller/TravelStandardPortController.java
  4. 189 0
      src/main/java/com/malk/minglei/dto/DingTalkDedicatedAccountCreateRequest.java
  5. 68 0
      src/main/java/com/malk/minglei/dto/DingTalkDedicatedAccountCreateResult.java
  6. 61 0
      src/main/java/com/malk/minglei/dto/FlightStandardQueryRequest.java
  7. 312 0
      src/main/java/com/malk/minglei/dto/TravelApplicationPushRequest.java
  8. 34 0
      src/main/java/com/malk/minglei/dto/TravelStandardQueryRequest.java
  9. 14 0
      src/main/java/com/malk/minglei/dto/banking/ElectronicBillDownloadRequest.java
  10. 34 0
      src/main/java/com/malk/minglei/dto/banking/ElectronicBillDownloadResult.java
  11. 353 0
      src/main/java/com/malk/minglei/service/impl/port/TravelApplicationPushServiceImpl.java
  12. 586 0
      src/main/java/com/malk/minglei/service/impl/port/TravelStandardPortServiceImpl.java
  13. 151 0
      src/main/java/com/malk/minglei/service/impl/yida/YidaFormInstanceClientImpl.java
  14. 73 0
      src/main/java/com/malk/minglei/service/impl/yida/YidaProcessInstanceClientImpl.java
  15. 19 0
      src/main/java/com/malk/minglei/service/port/TravelApplicationPushService.java
  16. 46 0
      src/main/java/com/malk/minglei/service/port/TravelStandardPortService.java
  17. 18 0
      src/main/java/com/malk/minglei/service/yida/YidaFormInstanceClient.java
  18. 18 0
      src/main/java/com/malk/minglei/service/yida/YidaProcessInstanceClient.java
  19. 42 0
      src/test/java/com/malk/minglei/config/BankingConfigurationTest.java
  20. 110 0
      src/test/java/com/malk/minglei/controller/BankingControllerTest.java
  21. 45 0
      src/test/java/com/malk/minglei/controller/DictionaryControllerTest.java
  22. 88 0
      src/test/java/com/malk/minglei/controller/ExpenseReimbursementControllerTest.java
  23. 26 0
      src/test/java/com/malk/minglei/controller/GlobalExceptionHandlerTest.java
  24. 81 0
      src/test/java/com/malk/minglei/controller/MingleiPortControllerTest.java
  25. 62 0
      src/test/java/com/malk/minglei/controller/TravelApplicationPushControllerTest.java
  26. 60 0
      src/test/java/com/malk/minglei/controller/TravelStandardPortControllerTest.java
  27. 390 0
      src/test/java/com/malk/minglei/service/impl/banking/BankingServiceImplTest.java
  28. 741 0
      src/test/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImplTest.java
  29. 107 0
      src/test/java/com/malk/minglei/service/impl/employee/DingTalkEmployeeRosterDuplicateNameNotifierTest.java
  30. 148 0
      src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepositoryTest.java
  31. 347 0
      src/test/java/com/malk/minglei/service/impl/port/SupplierPriceApprovalProcessServiceImplTest.java
  32. 127 0
      src/test/java/com/malk/minglei/service/impl/port/TravelApplicationPushServiceImplTest.java
  33. 273 0
      src/test/java/com/malk/minglei/service/impl/port/TravelStandardPortServiceImplTest.java
  34. 117 0
      src/test/java/com/malk/minglei/service/impl/yida/DingTalkYidaProcessOpenApiClientTest.java
  35. 71 0
      src/test/java/com/malk/minglei/service/impl/yida/YidaFormInstanceClientImplTest.java
  36. 54 0
      src/test/java/com/malk/minglei/service/impl/yida/YidaProcessInstanceClientImplTest.java

+ 46 - 0
src/main/java/com/malk/minglei/controller/ExpenseReimbursementController.java

@@ -0,0 +1,46 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.FlightStandardQueryRequest;
+import com.malk.minglei.service.port.TravelStandardPortService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.Map;
+
+/**
+ * 对外提供费用报销标准解析接口。
+ */
+@RestController
+@RequestMapping("/api/expense-reimbursement")
+public class ExpenseReimbursementController {
+
+    private final TravelStandardPortService travelStandardPortService;
+
+    /**
+     * 创建费用报销标准接口控制器。
+     *
+     * @param travelStandardPortService 差旅标准查询服务
+     */
+    public ExpenseReimbursementController(TravelStandardPortService travelStandardPortService) {
+        this.travelStandardPortService = travelStandardPortService;
+    }
+
+    /**
+     * 根据员工职等和机票选项解析可报销的机票上限。
+     *
+     * @param request 机票标准查询请求
+     * @return 机票报销标准
+     */
+    @PostMapping("/flight-standards/resolve")
+    public ResponseEntity<ApiResponse<Map<String, Object>>> resolveFlightStandard(
+            @Valid @RequestBody FlightStandardQueryRequest request) {
+        return ResponseEntity.ok(ApiResponse.success(travelStandardPortService.queryFlightStandard(
+                request.getUserId(), request.getTravelRegion(), request.getTicketType(),
+                request.getTripCount(), request.getBudgetAmount())));
+    }
+}

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

@@ -0,0 +1,45 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.TravelApplicationPushRequest;
+import com.malk.minglei.service.port.TravelApplicationPushService;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.Map;
+
+/**
+ * 接收钉钉 OA 已通过的出差审批数据并发起宜搭出差申请流程。
+ */
+@RestController
+@RequestMapping("/api/minglei/ports/travel-applications")
+public class TravelApplicationPushController {
+
+    private final TravelApplicationPushService travelApplicationPushService;
+
+    /**
+     * 创建出差申请推送接口控制器。
+     *
+     * @param travelApplicationPushService 出差申请推送服务
+     */
+    public TravelApplicationPushController(TravelApplicationPushService travelApplicationPushService) {
+        this.travelApplicationPushService = travelApplicationPushService;
+    }
+
+    /**
+     * 将 OA 主表、行程、飞机航程和费用预算转换为目标宜搭流程实例。
+     *
+     * @param request OA 连接器发来的已通过审批数据
+     * @return OA 审批实例与宜搭流程实例的关联结果
+     */
+    @PostMapping
+    public ResponseEntity<ApiResponse<Map<String, Object>>> push(@Valid @RequestBody TravelApplicationPushRequest request) {
+        return ResponseEntity.status(HttpStatus.CREATED)
+                .body(ApiResponse.success(travelApplicationPushService.push(request)));
+    }
+}

+ 46 - 0
src/main/java/com/malk/minglei/controller/TravelStandardPortController.java

@@ -0,0 +1,46 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.TravelStandardQueryRequest;
+import com.malk.minglei.service.port.TravelStandardPortService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+import java.util.Map;
+
+/**
+ * 对外提供按员工职等查询国内、国外差旅住宿标准的接口。
+ */
+@RestController
+@RequestMapping("/api/minglei/ports/travel-standards")
+public class TravelStandardPortController {
+
+    private final TravelStandardPortService travelStandardPortService;
+
+    /**
+     * 创建差旅标准接口控制器。
+     *
+     * @param travelStandardPortService 差旅标准查询服务
+     */
+    public TravelStandardPortController(TravelStandardPortService travelStandardPortService) {
+        this.travelStandardPortService = travelStandardPortService;
+    }
+
+    /**
+     * 根据钉钉用户 ID 查询员工职等,再返回该职等在指定国内或国外城市的住宿标准。
+     *
+     * @param request 差旅住宿标准请求
+     * @return 差旅住宿标准
+     */
+    @PostMapping
+    public ResponseEntity<ApiResponse<Map<String, Object>>> queryTravelStandards(
+            @Valid @RequestBody TravelStandardQueryRequest request) {
+        return ResponseEntity.ok(ApiResponse.success(travelStandardPortService.queryTravelStandards(
+                request.getUserId(), request.getCity())));
+    }
+}

+ 189 - 0
src/main/java/com/malk/minglei/dto/DingTalkDedicatedAccountCreateRequest.java

@@ -0,0 +1,189 @@
+package com.malk.minglei.dto;
+
+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;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 创建钉钉自建企业账号的请求参数。
+ *
+ * <p>部门选择字段保留 OA 组件的原始结构,由服务层统一提取为钉钉接口要求的部门 ID 列表。
+ */
+public class DingTalkDedicatedAccountCreateRequest {
+
+    @NotBlank
+    private String name;
+
+    @NotEmpty
+    @JsonProperty("departmentList")
+    @JsonAlias({"departments", "deptIdList", "dept_id_list"})
+    private List<Object> departmentList;
+
+    /**
+     * 员工工号。未传或仅包含空白字符时,由服务端按创建日期自动生成。
+     */
+    @Size(max = 64)
+    @JsonProperty("employeeNo")
+    @JsonAlias({"employee_no", "jobNumber", "job_number"})
+    private String employeeNo;
+
+    @Size(max = 128)
+    private String positionName;
+
+    private String gender;
+
+    @Min(0)
+    @Max(150)
+    private Integer age;
+
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private LocalDate expectedArrivalDate;
+
+    @Size(max = 128)
+    private String jobTitle;
+
+    @Size(max = 32)
+    private String jobGrade;
+
+    @Size(max = 64)
+    private String idCardNo;
+
+    /**
+     * 返回员工姓名。
+     *
+     * @return 员工姓名
+     */
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * 返回钉钉 OA 部门选择组件的原始选项列表。
+     *
+     * @return 原始部门选择结果
+     */
+    public List<Object> getDepartmentList() {
+        return departmentList;
+    }
+
+    public void setDepartmentList(List<Object> departmentList) {
+        this.departmentList = departmentList;
+    }
+
+    /**
+     * 返回调用方指定的员工工号。
+     *
+     * @return 员工工号;为空时由服务端生成
+     */
+    public String getEmployeeNo() {
+        return employeeNo;
+    }
+
+    public void setEmployeeNo(String employeeNo) {
+        this.employeeNo = employeeNo;
+    }
+
+    /**
+     * 返回员工岗位。
+     *
+     * @return 岗位名称
+     */
+    public String getPositionName() {
+        return positionName;
+    }
+
+    public void setPositionName(String positionName) {
+        this.positionName = positionName;
+    }
+
+    /**
+     * 返回员工性别,支持 1/2、男/女或 male/female。
+     *
+     * @return 性别
+     */
+    public String getGender() {
+        return gender;
+    }
+
+    public void setGender(String gender) {
+        this.gender = gender;
+    }
+
+    /**
+     * 返回员工年龄。
+     *
+     * @return 年龄
+     */
+    public Integer getAge() {
+        return age;
+    }
+
+    public void setAge(Integer age) {
+        this.age = age;
+    }
+
+    /**
+     * 返回预计到岗日期。
+     *
+     * @return 预计到岗日期
+     */
+    public LocalDate getExpectedArrivalDate() {
+        return expectedArrivalDate;
+    }
+
+    public void setExpectedArrivalDate(LocalDate expectedArrivalDate) {
+        this.expectedArrivalDate = expectedArrivalDate;
+    }
+
+    /**
+     * 返回员工职称。
+     *
+     * @return 职称
+     */
+    public String getJobTitle() {
+        return jobTitle;
+    }
+
+    public void setJobTitle(String jobTitle) {
+        this.jobTitle = jobTitle;
+    }
+
+    /**
+     * 返回员工职等。
+     *
+     * @return 职等
+     */
+    public String getJobGrade() {
+        return jobGrade;
+    }
+
+    public void setJobGrade(String jobGrade) {
+        this.jobGrade = jobGrade;
+    }
+
+    /**
+     * 返回员工身份证号。
+     *
+     * @return 身份证号
+     */
+    public String getIdCardNo() {
+        return idCardNo;
+    }
+
+    public void setIdCardNo(String idCardNo) {
+        this.idCardNo = idCardNo;
+    }
+
+}

+ 68 - 0
src/main/java/com/malk/minglei/dto/DingTalkDedicatedAccountCreateResult.java

@@ -0,0 +1,68 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 钉钉自建企业账号创建结果。
+ */
+public class DingTalkDedicatedAccountCreateResult {
+
+    private final String userId;
+    private final String employeeNo;
+    private final List<Long> departmentIds;
+
+    /**
+     * 创建账号结果。
+     *
+     * @param userId 钉钉返回的用户 ID
+     * @param departmentIds 已提交的钉钉部门 ID 列表
+     */
+    public DingTalkDedicatedAccountCreateResult(String userId, List<Long> departmentIds) {
+        this(userId, null, departmentIds);
+    }
+
+    /**
+     * 创建账号结果。
+     *
+     * @param userId 钉钉返回的用户 ID
+     * @param employeeNo 实际用于创建账号的员工工号
+     * @param departmentIds 已提交的钉钉部门 ID 列表
+     */
+    public DingTalkDedicatedAccountCreateResult(String userId, String employeeNo, List<Long> departmentIds) {
+        this.userId = userId;
+        this.employeeNo = employeeNo;
+        this.departmentIds = Collections.unmodifiableList(new ArrayList<Long>(departmentIds));
+    }
+
+    /**
+     * 返回钉钉用户 ID。
+     *
+     * @return 钉钉用户 ID
+     */
+    public String getUserId() {
+        return userId;
+    }
+
+    /**
+     * 返回实际用于创建账号的员工工号。
+     *
+     * @return 员工工号
+     */
+    @JsonProperty("employeeNo")
+    public String getEmployeeNo() {
+        return employeeNo;
+    }
+
+    /**
+     * 返回已提交的部门 ID。
+     *
+     * @return 部门 ID 列表
+     */
+    public List<Long> getDepartmentIds() {
+        return departmentIds;
+    }
+}

+ 61 - 0
src/main/java/com/malk/minglei/dto/FlightStandardQueryRequest.java

@@ -0,0 +1,61 @@
+package com.malk.minglei.dto;
+
+import javax.validation.constraints.NotBlank;
+import java.math.BigDecimal;
+
+/**
+ * 查询机票报销标准的请求参数。
+ */
+public class FlightStandardQueryRequest {
+
+    @NotBlank
+    private String userId;
+
+    private String travelRegion;
+
+    private String ticketType;
+
+    private Integer tripCount;
+
+    private BigDecimal budgetAmount;
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public void setUserId(String userId) {
+        this.userId = userId;
+    }
+
+    public String getTravelRegion() {
+        return travelRegion;
+    }
+
+    public void setTravelRegion(String travelRegion) {
+        this.travelRegion = travelRegion;
+    }
+
+    public String getTicketType() {
+        return ticketType;
+    }
+
+    public void setTicketType(String ticketType) {
+        this.ticketType = ticketType;
+    }
+
+    public Integer getTripCount() {
+        return tripCount;
+    }
+
+    public void setTripCount(Integer tripCount) {
+        this.tripCount = tripCount;
+    }
+
+    public BigDecimal getBudgetAmount() {
+        return budgetAmount;
+    }
+
+    public void setBudgetAmount(BigDecimal budgetAmount) {
+        this.budgetAmount = budgetAmount;
+    }
+}

+ 312 - 0
src/main/java/com/malk/minglei/dto/TravelApplicationPushRequest.java

@@ -0,0 +1,312 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 钉钉 OA 审批通过后推送出差申请流程的请求模型。
+ * <p>
+ * 所有人员字段接收钉钉 userId,部门字段接收钉钉 departmentId;附件不在本接口处理范围内。
+ */
+public class TravelApplicationPushRequest {
+
+    @NotBlank
+    @JsonAlias("processInstanceId")
+    private String oaProcessInstanceId;
+
+    @NotBlank
+    private String userId;
+
+    @NotBlank
+    private String departmentId;
+
+    @NotBlank
+    private String title;
+
+    private String company;
+    private String applyDate;
+    private String position;
+    private String mobile;
+    private List<String> actualTravelerUserIds = new ArrayList<String>();
+    private String employeeNo;
+    private String jobGrade;
+    private String travelType;
+    private String customerName;
+    private String travelReason;
+    private String transportation;
+    private String tripMode;
+
+    @Valid
+    private List<Itinerary> itineraries = new ArrayList<Itinerary>();
+
+    @Valid
+    private List<Flight> flights = new ArrayList<Flight>();
+
+    @Valid
+    private List<Budget> budgets = new ArrayList<Budget>();
+
+    public String getOaProcessInstanceId() {
+        return oaProcessInstanceId;
+    }
+
+    public void setOaProcessInstanceId(String oaProcessInstanceId) {
+        this.oaProcessInstanceId = oaProcessInstanceId;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public void setUserId(String userId) {
+        this.userId = userId;
+    }
+
+    public String getDepartmentId() {
+        return departmentId;
+    }
+
+    public void setDepartmentId(String departmentId) {
+        this.departmentId = departmentId;
+    }
+
+    public String getTitle() {
+        return title;
+    }
+
+    public void setTitle(String title) {
+        this.title = title;
+    }
+
+    public String getCompany() {
+        return company;
+    }
+
+    public void setCompany(String company) {
+        this.company = company;
+    }
+
+    public String getApplyDate() {
+        return applyDate;
+    }
+
+    public void setApplyDate(String applyDate) {
+        this.applyDate = applyDate;
+    }
+
+    public String getPosition() {
+        return position;
+    }
+
+    public void setPosition(String position) {
+        this.position = position;
+    }
+
+    public String getMobile() {
+        return mobile;
+    }
+
+    public void setMobile(String mobile) {
+        this.mobile = mobile;
+    }
+
+    public List<String> getActualTravelerUserIds() {
+        return actualTravelerUserIds;
+    }
+
+    public void setActualTravelerUserIds(List<String> actualTravelerUserIds) {
+        this.actualTravelerUserIds = actualTravelerUserIds;
+    }
+
+    public String getEmployeeNo() {
+        return employeeNo;
+    }
+
+    public void setEmployeeNo(String employeeNo) {
+        this.employeeNo = employeeNo;
+    }
+
+    public String getJobGrade() {
+        return jobGrade;
+    }
+
+    public void setJobGrade(String jobGrade) {
+        this.jobGrade = jobGrade;
+    }
+
+    public String getTravelType() {
+        return travelType;
+    }
+
+    public void setTravelType(String travelType) {
+        this.travelType = travelType;
+    }
+
+    public String getCustomerName() {
+        return customerName;
+    }
+
+    public void setCustomerName(String customerName) {
+        this.customerName = customerName;
+    }
+
+    public String getTravelReason() {
+        return travelReason;
+    }
+
+    public void setTravelReason(String travelReason) {
+        this.travelReason = travelReason;
+    }
+
+    public String getTransportation() {
+        return transportation;
+    }
+
+    public void setTransportation(String transportation) {
+        this.transportation = transportation;
+    }
+
+    public String getTripMode() {
+        return tripMode;
+    }
+
+    public void setTripMode(String tripMode) {
+        this.tripMode = tripMode;
+    }
+
+    public List<Itinerary> getItineraries() {
+        return itineraries;
+    }
+
+    public void setItineraries(List<Itinerary> itineraries) {
+        this.itineraries = itineraries;
+    }
+
+    public List<Flight> getFlights() {
+        return flights;
+    }
+
+    public void setFlights(List<Flight> flights) {
+        this.flights = flights;
+    }
+
+    public List<Budget> getBudgets() {
+        return budgets;
+    }
+
+    public void setBudgets(List<Budget> budgets) {
+        this.budgets = budgets;
+    }
+
+    /**
+     * 行程子表数据。
+     */
+    public static class Itinerary {
+        private String departureCity;
+        private String destinationCity;
+        private String startDate;
+        private String startPeriod;
+        private String endDate;
+        private String endPeriod;
+        private BigDecimal durationDays;
+        private BigDecimal travelDays;
+        private String remark;
+        private List<String> companionUserIds = new ArrayList<String>();
+        private String externalPersonnel;
+
+        public String getDepartureCity() { return departureCity; }
+        public void setDepartureCity(String departureCity) { this.departureCity = departureCity; }
+        public String getDestinationCity() { return destinationCity; }
+        public void setDestinationCity(String destinationCity) { this.destinationCity = destinationCity; }
+        public String getStartDate() { return startDate; }
+        public void setStartDate(String startDate) { this.startDate = startDate; }
+        public String getStartPeriod() { return startPeriod; }
+        public void setStartPeriod(String startPeriod) { this.startPeriod = startPeriod; }
+        public String getEndDate() { return endDate; }
+        public void setEndDate(String endDate) { this.endDate = endDate; }
+        public String getEndPeriod() { return endPeriod; }
+        public void setEndPeriod(String endPeriod) { this.endPeriod = endPeriod; }
+        public BigDecimal getDurationDays() { return durationDays; }
+        public void setDurationDays(BigDecimal durationDays) { this.durationDays = durationDays; }
+        public BigDecimal getTravelDays() { return travelDays; }
+        public void setTravelDays(BigDecimal travelDays) { this.travelDays = travelDays; }
+        public String getRemark() { return remark; }
+        public void setRemark(String remark) { this.remark = remark; }
+        public List<String> getCompanionUserIds() { return companionUserIds; }
+        public void setCompanionUserIds(List<String> companionUserIds) { this.companionUserIds = companionUserIds; }
+        public String getExternalPersonnel() { return externalPersonnel; }
+        public void setExternalPersonnel(String externalPersonnel) { this.externalPersonnel = externalPersonnel; }
+    }
+
+    /**
+     * 飞机航程子表数据。
+     */
+    public static class Flight {
+        private String ticketReserved;
+        private String travelRegion;
+        private String ticketType;
+        private Integer tripCount;
+        private BigDecimal budgetAmount;
+        private BigDecimal unitPrice;
+        private String overStandard;
+        private BigDecimal standardAmount;
+        private String remark;
+
+        public String getTicketReserved() { return ticketReserved; }
+        public void setTicketReserved(String ticketReserved) { this.ticketReserved = ticketReserved; }
+        public String getTravelRegion() { return travelRegion; }
+        public void setTravelRegion(String travelRegion) { this.travelRegion = travelRegion; }
+        public String getTicketType() { return ticketType; }
+        public void setTicketType(String ticketType) { this.ticketType = ticketType; }
+        public Integer getTripCount() { return tripCount; }
+        public void setTripCount(Integer tripCount) { this.tripCount = tripCount; }
+        public BigDecimal getBudgetAmount() { return budgetAmount; }
+        public void setBudgetAmount(BigDecimal budgetAmount) { this.budgetAmount = budgetAmount; }
+        public BigDecimal getUnitPrice() { return unitPrice; }
+        public void setUnitPrice(BigDecimal unitPrice) { this.unitPrice = unitPrice; }
+        public String getOverStandard() { return overStandard; }
+        public void setOverStandard(String overStandard) { this.overStandard = overStandard; }
+        public BigDecimal getStandardAmount() { return standardAmount; }
+        public void setStandardAmount(BigDecimal standardAmount) { this.standardAmount = standardAmount; }
+        public String getRemark() { return remark; }
+        public void setRemark(String remark) { this.remark = remark; }
+    }
+
+    /**
+     * 费用预算子表数据。
+     */
+    public static class Budget {
+        private String departureLocation;
+        private String arrivalLocation;
+        private BigDecimal accommodationDays;
+        private BigDecimal groundTransportationCost;
+        private BigDecimal accommodationCost;
+        private BigDecimal accommodationStandard;
+        private BigDecimal businessEntertainmentCost;
+        private BigDecimal travelAllowance;
+        private BigDecimal totalCost;
+
+        public String getDepartureLocation() { return departureLocation; }
+        public void setDepartureLocation(String departureLocation) { this.departureLocation = departureLocation; }
+        public String getArrivalLocation() { return arrivalLocation; }
+        public void setArrivalLocation(String arrivalLocation) { this.arrivalLocation = arrivalLocation; }
+        public BigDecimal getAccommodationDays() { return accommodationDays; }
+        public void setAccommodationDays(BigDecimal accommodationDays) { this.accommodationDays = accommodationDays; }
+        public BigDecimal getGroundTransportationCost() { return groundTransportationCost; }
+        public void setGroundTransportationCost(BigDecimal groundTransportationCost) { this.groundTransportationCost = groundTransportationCost; }
+        public BigDecimal getAccommodationCost() { return accommodationCost; }
+        public void setAccommodationCost(BigDecimal accommodationCost) { this.accommodationCost = accommodationCost; }
+        public BigDecimal getAccommodationStandard() { return accommodationStandard; }
+        public void setAccommodationStandard(BigDecimal accommodationStandard) { this.accommodationStandard = accommodationStandard; }
+        public BigDecimal getBusinessEntertainmentCost() { return businessEntertainmentCost; }
+        public void setBusinessEntertainmentCost(BigDecimal businessEntertainmentCost) { this.businessEntertainmentCost = businessEntertainmentCost; }
+        public BigDecimal getTravelAllowance() { return travelAllowance; }
+        public void setTravelAllowance(BigDecimal travelAllowance) { this.travelAllowance = travelAllowance; }
+        public BigDecimal getTotalCost() { return totalCost; }
+        public void setTotalCost(BigDecimal totalCost) { this.totalCost = totalCost; }
+    }
+}

+ 34 - 0
src/main/java/com/malk/minglei/dto/TravelStandardQueryRequest.java

@@ -0,0 +1,34 @@
+package com.malk.minglei.dto;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 查询员工差旅住宿标准的请求参数。
+ */
+public class TravelStandardQueryRequest {
+
+    @NotBlank
+    @JsonAlias("userID")
+    private String userId;
+
+    @NotBlank
+    private String city;
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public void setUserId(String userId) {
+        this.userId = userId;
+    }
+
+    public String getCity() {
+        return city;
+    }
+
+    public void setCity(String city) {
+        this.city = city;
+    }
+}

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

@@ -0,0 +1,14 @@
+package com.malk.minglei.dto.banking;
+
+/**
+ * 电子回单下载条件。uniqueNumber 与 businessTransNo 必须且只能传入一个。
+ */
+public class ElectronicBillDownloadRequest {
+    private String uniqueNumber;
+    private String businessTransNo;
+
+    public String getUniqueNumber() { return uniqueNumber; }
+    public void setUniqueNumber(String uniqueNumber) { this.uniqueNumber = uniqueNumber; }
+    public String getBusinessTransNo() { return businessTransNo; }
+    public void setBusinessTransNo(String businessTransNo) { this.businessTransNo = businessTransNo; }
+}

+ 34 - 0
src/main/java/com/malk/minglei/dto/banking/ElectronicBillDownloadResult.java

@@ -0,0 +1,34 @@
+package com.malk.minglei.dto.banking;
+
+/**
+ * 已保存电子回单的本地文件信息,不包含回单二进制内容。
+ */
+public class ElectronicBillDownloadResult {
+    private final String fileName;
+    private final String filePath;
+    private final long fileSize;
+    private final String contentType;
+    private final String previewUrl;
+    private final String downloadUrl;
+
+    public ElectronicBillDownloadResult(String fileName, String filePath, long fileSize, String contentType) {
+        this(fileName, filePath, fileSize, contentType, null, null);
+    }
+
+    public ElectronicBillDownloadResult(String fileName, String filePath, long fileSize, String contentType,
+                                        String previewUrl, String downloadUrl) {
+        this.fileName = fileName;
+        this.filePath = filePath;
+        this.fileSize = fileSize;
+        this.contentType = contentType;
+        this.previewUrl = previewUrl;
+        this.downloadUrl = downloadUrl;
+    }
+
+    public String getFileName() { return fileName; }
+    public String getFilePath() { return filePath; }
+    public long getFileSize() { return fileSize; }
+    public String getContentType() { return contentType; }
+    public String getPreviewUrl() { return previewUrl; }
+    public String getDownloadUrl() { return downloadUrl; }
+}

+ 353 - 0
src/main/java/com/malk/minglei/service/impl/port/TravelApplicationPushServiceImpl.java

@@ -0,0 +1,353 @@
+package com.malk.minglei.service.impl.port;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.TravelApplicationPushRequest;
+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 org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 将钉钉 OA 已通过的出差审批数据映射到宜搭出差申请流程。
+ * <p>
+ * 该实现使用出差申请流程所属的协同办公宜搭应用凭据,人员和部门字段只传递钉钉 ID,避免依赖名称反查。
+ */
+@Service
+public class TravelApplicationPushServiceImpl implements TravelApplicationPushService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(TravelApplicationPushServiceImpl.class);
+
+    private static final String FORM_UUID = "FORM-E1A24F72B06247ADAC36335CF7E1FEAC6DJX";
+    private static final String PROCESS_CODE = "TPROC--CNG66SB108EACFIFLA9FL5E4D5R12OYJNROVM0";
+    private static final String FORM_NAME = "出差申请流程";
+
+    private static final String TITLE_FIELD_ID = "textField_msh27unu";
+    private static final String APPLICANT_FIELD_ID = "employeeField_msh186rr";
+    private static final String COMPANY_FIELD_ID = "textField_mu208bk1";
+    private static final String DEPARTMENT_FIELD_ID = "departmentSelectField_msh186ry";
+    private static final String APPLY_DATE_FIELD_ID = "dateField_msh186rs";
+    private static final String POSITION_FIELD_ID = "textField_mspqoa00";
+    private static final String MOBILE_FIELD_ID = "textField_mspqoa01";
+    private static final String FORM_TITLE_FIELD_ID = "textField_mspcpz92";
+    private static final String ACTUAL_TRAVELER_FIELD_ID = "employeeField_mvorr6nc";
+    private static final String EMPLOYEE_NO_FIELD_ID = "textField_mvorr6nd";
+    private static final String JOB_GRADE_FIELD_ID = "textField_mvorr6ne";
+    private static final String TRAVEL_TYPE_FIELD_ID = "selectField_mvorr6ng";
+    private static final String CUSTOMER_FIELD_ID = "textField_mvorr6nh";
+    private static final String TRAVEL_REASON_FIELD_ID = "textareaField_mvorr6nj";
+    private static final String TRANSPORTATION_FIELD_ID = "selectField_mvorr6nk";
+    private static final String TRIP_MODE_FIELD_ID = "selectField_mvorr6nl";
+
+    private static final String ITINERARY_TABLE_FIELD_ID = "tableField_mvorr6o1";
+    private static final String ITINERARY_DEPARTURE_CITY_FIELD_ID = "selectField_mvorr6nv";
+    private static final String ITINERARY_DESTINATION_CITY_FIELD_ID = "selectField_mvorr6nu";
+    private static final String ITINERARY_START_DATE_FIELD_ID = "dateField_mvorr6nx";
+    private static final String ITINERARY_START_PERIOD_FIELD_ID = "selectField_mvorr6ny";
+    private static final String ITINERARY_END_DATE_FIELD_ID = "dateField_mvorr6nz";
+    private static final String ITINERARY_END_PERIOD_FIELD_ID = "selectField_mvorr6nw";
+    private static final String ITINERARY_DURATION_FIELD_ID = "numberField_mvorr6o0";
+    private static final String ITINERARY_TRAVEL_DAYS_FIELD_ID = "numberField_mvorr6o6";
+    private static final String ITINERARY_REMARK_FIELD_ID = "textareaField_mvorr6o7";
+    private static final String ITINERARY_COMPANION_FIELD_ID = "employeeField_mvorr6o8";
+    private static final String ITINERARY_EXTERNAL_PERSONNEL_FIELD_ID = "textareaField_mvorr6o9";
+
+    private static final String FLIGHT_TABLE_FIELD_ID = "tableField_mvorr6oe";
+    private static final String FLIGHT_RESERVED_FIELD_ID = "selectField_mvorr6of";
+    private static final String FLIGHT_REGION_FIELD_ID = "selectField_mvorr6og";
+    private static final String FLIGHT_TYPE_FIELD_ID = "selectField_mvorr6oh";
+    private static final String FLIGHT_COUNT_FIELD_ID = "numberField_mvorr6oi";
+    private static final String FLIGHT_BUDGET_FIELD_ID = "numberField_mvorr6oj";
+    private static final String FLIGHT_UNIT_PRICE_FIELD_ID = "numberField_mvorr6ok";
+    private static final String FLIGHT_STANDARD_AMOUNT_FIELD_ID = "numberField_mvorr6ol";
+    private static final String FLIGHT_OVER_STANDARD_FIELD_ID = "selectField_mvorr6om";
+    private static final String FLIGHT_REMARK_FIELD_ID = "textareaField_mvorr6on";
+
+    private static final String BUDGET_TABLE_FIELD_ID = "tableField_mvorr6oo";
+    private static final String BUDGET_DEPARTURE_LOCATION_FIELD_ID = "selectField_mvorr6op";
+    private static final String BUDGET_ARRIVAL_LOCATION_FIELD_ID = "selectField_mvorr6oq";
+    private static final String BUDGET_ACCOMMODATION_DAYS_FIELD_ID = "numberField_mvorr6or";
+    private static final String BUDGET_GROUND_TRANSPORTATION_FIELD_ID = "numberField_mvorr6os";
+    private static final String BUDGET_ACCOMMODATION_COST_FIELD_ID = "numberField_mvorr6ot";
+    private static final String BUDGET_ACCOMMODATION_STANDARD_FIELD_ID = "numberField_mvorr6ou";
+    private static final String BUDGET_BUSINESS_ENTERTAINMENT_FIELD_ID = "numberField_mvorr6ov";
+    private static final String BUDGET_TRAVEL_ALLOWANCE_FIELD_ID = "numberField_mvorr6ow";
+    private static final String BUDGET_TOTAL_COST_FIELD_ID = "numberField_mvorr6ox";
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final DDConf dingTalkConfiguration;
+    private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
+
+    /**
+     * 创建出差申请推送服务。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     */
+    public TravelApplicationPushServiceImpl(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                            DDConf dingTalkConfiguration,
+                                            YidaApplicationsProperties applications) {
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.dingTalkConfiguration = dingTalkConfiguration;
+        this.coordinationOfficeConfiguration = applications.getCoordinationOffice();
+    }
+
+    /**
+     * 发起新的宜搭出差申请流程。
+     *
+     * @param request 已通过 OA 审批的主表和三张子表数据
+     * @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);
+        Map<String, Object> body = new LinkedHashMap<String, Object>();
+        // 流程编码只在协同办公应用内有效,必须使用该应用的 appType 和 systemToken 发起流程。
+        body.put("appType", requireText(coordinationOfficeConfiguration.getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured"));
+        body.put("systemToken", requireText(coordinationOfficeConfiguration.getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured"));
+        body.put("userId", userId);
+        body.put("processCode", PROCESS_CODE);
+        body.put("formUuid", FORM_UUID);
+        body.put("formDataJson", JSON.toJSONString(formData));
+        body.put("executeExpression", true);
+        body.put("noExecuteExpression", false);
+        body.put("title", title);
+        body.put("dataTitle", title);
+
+        // 仅记录追踪 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()));
+        String processInstanceId;
+        try {
+            processInstanceId = extractProcessInstanceId(yidaProcessOpenApiClient.startProcess(body));
+        } catch (McException exception) {
+            throw exception;
+        } catch (Exception exception) {
+            throw new McException("DINGTALK_YIDA_ERROR", "Yida travel application process start failed: " + rootMessage(exception));
+        }
+
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("oaProcessInstanceId", request.getOaProcessInstanceId());
+        result.put("yidaProcessInstanceId", processInstanceId);
+        result.put("formUuid", FORM_UUID);
+        return result;
+    }
+
+    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, APPLY_DATE_FIELD_ID, toDateMillis(request.getApplyDate()));
+        putIfPresent(formData, POSITION_FIELD_ID, request.getPosition());
+        putIfPresent(formData, MOBILE_FIELD_ID, request.getMobile());
+        putIfPresent(formData, FORM_TITLE_FIELD_ID, title);
+        putIfPresent(formData, ACTUAL_TRAVELER_FIELD_ID, nonEmptyUserIds(request.getActualTravelerUserIds(), userId));
+        putIfPresent(formData, EMPLOYEE_NO_FIELD_ID, request.getEmployeeNo());
+        putIfPresent(formData, JOB_GRADE_FIELD_ID, request.getJobGrade());
+        putIfPresent(formData, TRAVEL_TYPE_FIELD_ID, request.getTravelType());
+        putIfPresent(formData, CUSTOMER_FIELD_ID, request.getCustomerName());
+        putIfPresent(formData, TRAVEL_REASON_FIELD_ID, request.getTravelReason());
+        putIfPresent(formData, TRANSPORTATION_FIELD_ID, request.getTransportation());
+        putIfPresent(formData, TRIP_MODE_FIELD_ID, request.getTripMode());
+
+        putIfPresent(formData, ITINERARY_TABLE_FIELD_ID, toItineraryRows(request.getItineraries()));
+        putIfPresent(formData, FLIGHT_TABLE_FIELD_ID, toFlightRows(request.getFlights()));
+        putIfPresent(formData, BUDGET_TABLE_FIELD_ID, toBudgetRows(request.getBudgets()));
+        return formData;
+    }
+
+    private List<Map<String, Object>> toItineraryRows(List<TravelApplicationPushRequest.Itinerary> itineraries) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        if (itineraries == null) {
+            return rows;
+        }
+        for (TravelApplicationPushRequest.Itinerary itinerary : itineraries) {
+            if (itinerary == null) {
+                continue;
+            }
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfPresent(row, ITINERARY_DEPARTURE_CITY_FIELD_ID, itinerary.getDepartureCity());
+            putIfPresent(row, ITINERARY_DESTINATION_CITY_FIELD_ID, itinerary.getDestinationCity());
+            putIfPresent(row, ITINERARY_START_DATE_FIELD_ID, toDateMillis(itinerary.getStartDate()));
+            putIfPresent(row, ITINERARY_START_PERIOD_FIELD_ID, itinerary.getStartPeriod());
+            putIfPresent(row, ITINERARY_END_DATE_FIELD_ID, toDateMillis(itinerary.getEndDate()));
+            putIfPresent(row, ITINERARY_END_PERIOD_FIELD_ID, itinerary.getEndPeriod());
+            putIfPresent(row, ITINERARY_DURATION_FIELD_ID, itinerary.getDurationDays());
+            putIfPresent(row, ITINERARY_TRAVEL_DAYS_FIELD_ID, itinerary.getTravelDays());
+            putIfPresent(row, ITINERARY_REMARK_FIELD_ID, itinerary.getRemark());
+            putIfPresent(row, ITINERARY_COMPANION_FIELD_ID, nonEmptyUserIds(itinerary.getCompanionUserIds(), null));
+            putIfPresent(row, ITINERARY_EXTERNAL_PERSONNEL_FIELD_ID, itinerary.getExternalPersonnel());
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<Map<String, Object>> toFlightRows(List<TravelApplicationPushRequest.Flight> flights) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        if (flights == null) {
+            return rows;
+        }
+        for (TravelApplicationPushRequest.Flight flight : flights) {
+            if (flight == null) {
+                continue;
+            }
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfPresent(row, FLIGHT_RESERVED_FIELD_ID, flight.getTicketReserved());
+            putIfPresent(row, FLIGHT_REGION_FIELD_ID, flight.getTravelRegion());
+            putIfPresent(row, FLIGHT_TYPE_FIELD_ID, flight.getTicketType());
+            putIfPresent(row, FLIGHT_COUNT_FIELD_ID, flight.getTripCount());
+            putIfPresent(row, FLIGHT_BUDGET_FIELD_ID, flight.getBudgetAmount());
+            putIfPresent(row, FLIGHT_UNIT_PRICE_FIELD_ID, flight.getUnitPrice());
+            putIfPresent(row, FLIGHT_STANDARD_AMOUNT_FIELD_ID, flight.getStandardAmount());
+            putIfPresent(row, FLIGHT_OVER_STANDARD_FIELD_ID, flight.getOverStandard());
+            putIfPresent(row, FLIGHT_REMARK_FIELD_ID, flight.getRemark());
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<Map<String, Object>> toBudgetRows(List<TravelApplicationPushRequest.Budget> budgets) {
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        if (budgets == null) {
+            return rows;
+        }
+        for (TravelApplicationPushRequest.Budget budget : budgets) {
+            if (budget == null) {
+                continue;
+            }
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            putIfPresent(row, BUDGET_DEPARTURE_LOCATION_FIELD_ID, budget.getDepartureLocation());
+            putIfPresent(row, BUDGET_ARRIVAL_LOCATION_FIELD_ID, budget.getArrivalLocation());
+            putIfPresent(row, BUDGET_ACCOMMODATION_DAYS_FIELD_ID, budget.getAccommodationDays());
+            putIfPresent(row, BUDGET_GROUND_TRANSPORTATION_FIELD_ID, budget.getGroundTransportationCost());
+            putIfPresent(row, BUDGET_ACCOMMODATION_COST_FIELD_ID, budget.getAccommodationCost());
+            putIfPresent(row, BUDGET_ACCOMMODATION_STANDARD_FIELD_ID, budget.getAccommodationStandard());
+            putIfPresent(row, BUDGET_BUSINESS_ENTERTAINMENT_FIELD_ID, budget.getBusinessEntertainmentCost());
+            putIfPresent(row, BUDGET_TRAVEL_ALLOWANCE_FIELD_ID, budget.getTravelAllowance());
+            putIfPresent(row, BUDGET_TOTAL_COST_FIELD_ID, budget.getTotalCost());
+            if (!row.isEmpty()) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    private List<String> nonEmptyUserIds(List<String> userIds, String fallbackUserId) {
+        List<String> result = new ArrayList<String>();
+        if (userIds != null) {
+            for (String userId : userIds) {
+                if (!isBlank(userId)) {
+                    result.add(userId.trim());
+                }
+            }
+        }
+        if (result.isEmpty() && !isBlank(fallbackUserId)) {
+            result.add(fallbackUserId.trim());
+        }
+        return result;
+    }
+
+    private Long toDateMillis(String value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        String text = value.trim();
+        if (text.matches("\\d+")) {
+            return Long.valueOf(text);
+        }
+        try {
+            return LocalDate.parse(text.replace('/', '-')).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+        } catch (RuntimeException exception) {
+            throw new IllegalArgumentException("Date must be yyyy-MM-dd or epoch milliseconds: " + value);
+        }
+    }
+
+    private void putIfPresent(Map<String, Object> target, String fieldId, Object value) {
+        if (value == null) {
+            return;
+        }
+        if (value instanceof String && isBlank((String) value)) {
+            return;
+        }
+        if (value instanceof List && ((List<?>) value).isEmpty()) {
+            return;
+        }
+        target.put(fieldId, value);
+    }
+
+    private String extractProcessInstanceId(DDR_New response) {
+        if (response == null) {
+            throw new McException("YIDA_NULL_RESPONSE", "Yida travel application process start failed: empty response");
+        }
+        Object result = firstNonBlank(firstNonBlank(response.getResult(), response.getInstanceId()), response.getFormInstId());
+        if (!isBlank(result)) {
+            return String.valueOf(result);
+        }
+        if (response.isSuccess()) {
+            return "SUCCESS";
+        }
+        throw new McException(defaultText(response.getCode(), "YIDA_ERROR"),
+                "Yida travel application process start failed: " + defaultText(response.getMessage(), "no process instance id"));
+    }
+
+    private Object firstNonBlank(Object first, Object second) {
+        return isBlank(first) ? second : first;
+    }
+
+    private boolean isBlank(Object value) {
+        return value == null || String.valueOf(value).trim().isEmpty();
+    }
+
+    private int sizeOf(List<?> values) {
+        return values == null ? 0 : values.size();
+    }
+
+    private String rootMessage(Throwable exception) {
+        Throwable current = exception;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        return defaultText(current.getMessage(), current.getClass().getSimpleName());
+    }
+
+    private String requireText(String value, String message) {
+        if (isBlank(value)) {
+            throw new IllegalArgumentException(message);
+        }
+        return value.trim();
+    }
+
+    private String defaultText(String value, String fallback) {
+        return isBlank(value) ? fallback : value.trim();
+    }
+
+    private void validateConfiguration() {
+        requireText(dingTalkConfiguration.getAppKey(), "DINGTALK_APP_KEY is not configured");
+        requireText(dingTalkConfiguration.getAppSecret(), "DINGTALK_APP_SECRET is not configured");
+        requireText(coordinationOfficeConfiguration.getAppType(), "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        requireText(coordinationOfficeConfiguration.getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+    }
+}

+ 586 - 0
src/main/java/com/malk/minglei/service/impl/port/TravelStandardPortServiceImpl.java

@@ -0,0 +1,586 @@
+package com.malk.minglei.service.impl.port;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import com.malk.minglei.service.port.TravelStandardPortService;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 根据员工花名册职等和宜搭差旅标准档案查询住宿及机票标准。
+ */
+@Service
+public class TravelStandardPortServiceImpl implements TravelStandardPortService {
+
+    private static final String TRAVEL_STANDARD_FORM_UUID = "FORM-47ED3C35E3C34A9CA275039F93FA5ED1VR2J";
+    private static final String JOB_GRADE_FIELD_ID = "numberField_mvevi2ck";
+    private static final String FIRST_CITY_FIELD_ID = "numberField_mvevi2ct";
+    private static final String SECOND_CITY_FIELD_ID = "numberField_mvevi2cu";
+    private static final String THIRD_CITY_FIELD_ID = "numberField_mvevi2cv";
+    private static final String FIRST_COUNTRY_CNY_FIELD_ID = "numberField_mvevi2d3";
+    private static final String OTHER_COUNTRY_CNY_FIELD_ID = "numberField_mvevi2d4";
+    private static final String DOMESTIC_ROUND_TRIP_FIELD_ID = "numberField_mvevi2dc";
+    private static final String DOMESTIC_ONE_WAY_FIELD_ID = "numberField_mvevi2dd";
+    private static final String SOUTHEAST_ASIA_ROUND_TRIP_FIELD_ID = "numberField_mvevi2de";
+    private static final String SOUTHEAST_ASIA_ONE_WAY_FIELD_ID = "numberField_mvevi2dg";
+    private static final String EUROPE_AMERICA_OTHER_ROUND_TRIP_FIELD_ID = "numberField_mvevi2dj";
+    private static final String EUROPE_AMERICA_OTHER_ONE_WAY_FIELD_ID = "numberField_mvevi2dl";
+    private static final int QUERY_PAGE_SIZE = 10;
+
+    private static final Set<String> FIRST_CLASS_CITIES = immutableSet("北京", "天津", "上海", "重庆",
+            "深圳", "珠海", "汕头", "厦门");
+    private static final Set<String> SECOND_CLASS_CITIES = immutableSet(
+            "石家庄", "太原", "呼和浩特", "沈阳", "长春", "哈尔滨", "南京", "杭州", "合肥", "福州",
+            "南昌", "济南", "郑州", "武汉", "长沙", "广州", "南宁", "成都", "贵阳", "昆明", "拉萨",
+            "西安", "兰州", "西宁", "银川", "乌鲁木齐", "大连", "青岛", "宁波",
+            "海南", "海口", "三亚", "三沙", "儋州", "五指山", "琼海", "文昌", "万宁", "东方",
+            "定安", "屯昌", "澄迈", "临高", "白沙", "昌江", "乐东", "陵水", "保亭", "琼中");
+    private static final Set<String> FIRST_CLASS_COUNTRY_CODES = immutableSet(
+            "US", "CA", "GB", "FR", "DE", "IT", "JP", "AU");
+    private static final Map<String, String> INTERNATIONAL_CITY_COUNTRY_CODES = createInternationalCityCountryCodes();
+    private static final Map<String, String> ISO_COUNTRY_CODES = createIsoCountryCodes();
+    private static final Map<String, FlightOption> FLIGHT_OPTIONS = createFlightOptions();
+
+    private final EmployeeRosterService employeeRosterService;
+    private final YDClient yidaClient;
+    private final YidaApplicationsProperties.ApplicationCredentials masterDataConfiguration;
+    private final ObjectMapper objectMapper;
+
+    /**
+     * 创建差旅标准查询服务。
+     *
+     * @param employeeRosterService 本地员工花名册查询服务
+     * @param yidaClient 审批矩阵复用的主数据宜搭表单客户端
+     * @param applications 宜搭应用配置
+     * @param objectMapper JSON 序列化组件
+     */
+    public TravelStandardPortServiceImpl(EmployeeRosterService employeeRosterService,
+                                         @Qualifier("masterDataYidaClient") YDClient yidaClient,
+                                         YidaApplicationsProperties applications,
+                                         ObjectMapper objectMapper) {
+        this.employeeRosterService = employeeRosterService;
+        this.yidaClient = yidaClient;
+        this.masterDataConfiguration = applications.getMasterData();
+        this.objectMapper = objectMapper;
+    }
+
+    /**
+     * 先查询员工花名册的职等,再按职等查询宜搭差旅档案,最后依据城市或国家类别选择住宿金额。
+     *
+     * @param userId 钉钉用户 ID
+     * @param city 出差城市,可传国内或国外城市
+     * @return 当前员工在目标城市的住宿标准
+     */
+    @Override
+    public Map<String, Object> queryTravelStandards(String userId, String city) {
+        String requestedUserId = requireText(userId, "userID must not be blank");
+        String requestedCity = requireText(city, "city must not be blank");
+
+        EmployeeRosterInfo employee = employeeRosterService.findByUserId(requestedUserId);
+        if (employee == null) {
+            throw new IllegalArgumentException("Employee was not found for userID: " + requestedUserId);
+        }
+        String jobGrade = requireText(employee.getJobGrade(), "Employee job grade is not configured for userID: " + requestedUserId);
+        BigDecimal numericJobGrade = parseNumber(jobGrade, "Employee job grade is invalid for userID: " + requestedUserId);
+
+        Map<?, ?> standardRecord = findTravelStandard(numericJobGrade, jobGrade);
+        AccommodationLocation location = resolveAccommodationLocation(requestedCity);
+        BigDecimal amount = readAmount(standardRecord, location.getAmountFieldId(), jobGrade);
+
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("userID", requestedUserId);
+        result.put("jobGrade", jobGrade);
+        result.put("city", requestedCity);
+        result.put("country", location.getCountry());
+        result.put("cityClass", location.getCityClass());
+        result.put("amount", amount);
+        result.put("currency", "CNY");
+        return result;
+    }
+
+    /**
+     * 根据机票选项读取同一职等差旅标准主表中的对应金额。
+     *
+     * @param userId 钉钉用户 ID
+     * @param travelRegion 出差区域编码
+     * @param ticketType 机票类型
+     * @return 当前员工的机票报销上限
+     */
+    @Override
+    public Map<String, Object> queryFlightStandard(String userId, String travelRegion, String ticketType) {
+        return queryFlightStandard(userId, travelRegion, ticketType, null, null);
+    }
+
+    /**
+     * 根据机票次数与预算金额判断是否超过该员工、该行程选项的总机票标准。
+     *
+     * @param userId 钉钉用户 ID
+     * @param travelRegion 出差区域编码
+     * @param ticketType 机票类型
+     * @param tripCount 机票次数
+     * @param budgetAmount 预算金额
+     * @return 当前员工的机票报销上限及超标状态
+     */
+    @Override
+    public Map<String, Object> queryFlightStandard(String userId, String travelRegion, String ticketType,
+                                                     Integer tripCount, BigDecimal budgetAmount) {
+        String requestedUserId = requireText(userId, "userId must not be blank");
+        String requestedTravelRegion = trimToEmpty(travelRegion);
+        String requestedTicketType = trimToEmpty(ticketType);
+        // 宜搭主表按“区域 + 单程/往返”分别维护金额,接口传入中文选项后在服务端统一映射。
+        String requestedFlightOption = flightOptionOf(requestedTravelRegion, requestedTicketType);
+        FlightOption option = FLIGHT_OPTIONS.get(requestedFlightOption);
+        if (option == null) {
+            return unmatchedFlightStandard(requestedUserId, requestedTravelRegion, requestedTicketType,
+                    tripCount, budgetAmount);
+        }
+
+        EmployeeRosterInfo employee = employeeRosterService.findByUserId(requestedUserId);
+        if (employee == null) {
+            throw new IllegalArgumentException("Employee was not found for userId: " + requestedUserId);
+        }
+        String jobGrade = requireText(employee.getJobGrade(), "Employee job grade is not configured for userId: " + requestedUserId);
+        BigDecimal numericJobGrade = parseNumber(jobGrade, "Employee job grade is invalid for userId: " + requestedUserId);
+        Map<?, ?> standardRecord = findTravelStandard(numericJobGrade, jobGrade);
+        BigDecimal amount = readAmount(standardRecord, option.getFieldId(), jobGrade);
+
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("expenseType", "FLIGHT");
+        result.put("userId", requestedUserId);
+        result.put("jobGrade", jobGrade);
+        result.put("jobGradeId", employee.getJobGradeId());
+        result.put("travelRegion", requestedTravelRegion);
+        result.put("ticketType", requestedTicketType);
+        result.put("flightOption", requestedFlightOption);
+        result.put("flightOptionName", option.getName());
+        result.put("limitAmount", amount);
+        result.put("tripCount", tripCount);
+        result.put("budgetAmount", budgetAmount);
+        result.put("isOverStandard", overStandardStatus(amount, tripCount, budgetAmount));
+        result.put("currency", "CNY");
+        result.put("unit", "PER_TRIP");
+        return result;
+    }
+
+    /**
+     * 区域或机票类型未命中预置标准时,保持接口成功并返回空的标准字段,便于调用方自行处理。
+     */
+    private Map<String, Object> unmatchedFlightStandard(String userId, String travelRegion, String ticketType,
+                                                         Integer tripCount, BigDecimal budgetAmount) {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("expenseType", "FLIGHT");
+        result.put("userId", userId);
+        result.put("jobGrade", null);
+        result.put("jobGradeId", null);
+        result.put("travelRegion", travelRegion);
+        result.put("ticketType", ticketType);
+        result.put("flightOption", null);
+        result.put("flightOptionName", null);
+        result.put("limitAmount", null);
+        result.put("tripCount", tripCount);
+        result.put("budgetAmount", budgetAmount);
+        result.put("isOverStandard", null);
+        result.put("currency", null);
+        result.put("unit", null);
+        return result;
+    }
+
+    /**
+     * 预算严格超过“单次标准金额乘以次数”才视为超标;相等时仍属于标准范围内。
+     */
+    private String overStandardStatus(BigDecimal limitAmount, Integer tripCount, BigDecimal budgetAmount) {
+        if (limitAmount == null || tripCount == null || budgetAmount == null) {
+            return null;
+        }
+        BigDecimal totalLimitAmount = limitAmount.multiply(BigDecimal.valueOf(tripCount.longValue()));
+        return budgetAmount.compareTo(totalLimitAmount) > 0 ? "是" : "否";
+    }
+
+    private Map<?, ?> findTravelStandard(BigDecimal numericJobGrade, String jobGrade) {
+        validateConfiguration();
+
+        YDParam parameter = new YDParam();
+        parameter.setAppType(masterDataConfiguration.getAppType());
+        parameter.setSystemToken(masterDataConfiguration.getSystemToken());
+        parameter.setFormUuid(TRAVEL_STANDARD_FORM_UUID);
+        parameter.setCurrentPage(1);
+        parameter.setPageSize(QUERY_PAGE_SIZE);
+        parameter.setSearchFieldJson(createJobGradeSearchFieldJson(numericJobGrade));
+
+        DDR_New response = yidaClient.queryData(parameter, YDConf.FORM_QUERY.retrieve_search_form);
+        if (response == null) {
+            throw new IllegalStateException("Yida travel standard query returned an empty response");
+        }
+        response.assertSuccess();
+
+        Map<?, ?> standardRecord = firstRecordForJobGrade(response.getRecords(), numericJobGrade);
+        if (standardRecord == null) {
+            standardRecord = firstRecordForJobGrade(response.getResult(), numericJobGrade);
+        }
+        if (standardRecord == null) {
+            standardRecord = firstRecordForJobGrade(response.getData(), numericJobGrade);
+        }
+        if (standardRecord == null) {
+            throw new IllegalArgumentException("No Yida travel standard was found for job grade: " + jobGrade);
+        }
+        return standardRecord;
+    }
+
+    private Map<?, ?> firstRecordForJobGrade(Object records, BigDecimal numericJobGrade) {
+        if (records instanceof Map) {
+            Map<?, ?> recordMap = (Map<?, ?>) records;
+            Map<?, ?> matched = matchJobGrade(recordMap, numericJobGrade);
+            if (matched != null) {
+                return matched;
+            }
+            matched = firstRecordForJobGrade(recordMap.get("records"), numericJobGrade);
+            if (matched != null) {
+                return matched;
+            }
+            matched = firstRecordForJobGrade(recordMap.get("result"), numericJobGrade);
+            return matched != null ? matched : firstRecordForJobGrade(recordMap.get("data"), numericJobGrade);
+        }
+        if (!(records instanceof Iterable)) {
+            return null;
+        }
+        for (Object record : (Iterable<?>) records) {
+            if (record instanceof Map) {
+                Map<?, ?> matched = matchJobGrade((Map<?, ?>) record, numericJobGrade);
+                if (matched != null) {
+                    return matched;
+                }
+            }
+        }
+        return null;
+    }
+
+    private Map<?, ?> matchJobGrade(Map<?, ?> record, BigDecimal numericJobGrade) {
+        Map<?, ?> fields = formDataOf(record);
+        if (fields == null) {
+            return null;
+        }
+        BigDecimal recordJobGrade = numberOrNull(fields.get(JOB_GRADE_FIELD_ID));
+        return recordJobGrade != null && numericJobGrade.compareTo(recordJobGrade) == 0 ? fields : null;
+    }
+
+    private Map<?, ?> formDataOf(Map<?, ?> record) {
+        Object formData = record.get("formData");
+        if (formData instanceof Map) {
+            return (Map<?, ?>) formData;
+        }
+        if (!(formData instanceof String)) {
+            return null;
+        }
+        try {
+            return objectMapper.readValue((String) formData, Map.class);
+        } catch (Exception exception) {
+            return null;
+        }
+    }
+
+    private BigDecimal readAmount(Map<?, ?> standardRecord, String fieldId, String jobGrade) {
+        BigDecimal amount = numberOrNull(standardRecord.get(fieldId));
+        if (amount == null) {
+            throw new IllegalArgumentException("Yida travel standard amount is not configured for job grade: " + jobGrade);
+        }
+        return amount;
+    }
+
+    private String resolveCityClass(String city) {
+        String normalizedCity = normalizeLocation(city);
+        if (FIRST_CLASS_CITIES.contains(normalizedCity)) {
+            return "一类城市";
+        }
+        // 海南全域按二类城市执行,兼容“海南省海口市”这类含省市全称的输入。
+        if (normalizedCity.startsWith("海南") || SECOND_CLASS_CITIES.contains(normalizedCity)) {
+            return "二类城市";
+        }
+        return "三类城市";
+    }
+
+    /**
+     * 将城市归为国内城市、指定一类国家或其他国家,并选择宜搭中以人民币维护的住宿金额字段。
+     * 对无法识别国家的中文城市保留历史的国内三类城市兜底,避免改变已有国内调用结果。
+     */
+    private AccommodationLocation resolveAccommodationLocation(String city) {
+        String countryCode = resolveInternationalCountryCode(city);
+        if (countryCode == null || "CN".equals(countryCode)) {
+            String cityClass = resolveCityClass(city);
+            return new AccommodationLocation("中国", cityClass, fieldIdForCityClass(cityClass));
+        }
+        String country = displayCountry(countryCode);
+        if (FIRST_CLASS_COUNTRY_CODES.contains(countryCode)) {
+            return new AccommodationLocation(country, "一类国家", FIRST_COUNTRY_CNY_FIELD_ID);
+        }
+        return new AccommodationLocation(country, "其他国家", OTHER_COUNTRY_CNY_FIELD_ID);
+    }
+
+    /**
+     * 优先识别常用城市;若调用方在城市值中附带国家名称(如“巴黎(法国)”),再通过 JDK 国家库识别。
+     */
+    private String resolveInternationalCountryCode(String city) {
+        String normalizedCity = normalizeLocation(city);
+        String countryCode = INTERNATIONAL_CITY_COUNTRY_CODES.get(normalizedCity);
+        if (countryCode != null) {
+            return countryCode;
+        }
+        for (Map.Entry<String, String> entry : ISO_COUNTRY_CODES.entrySet()) {
+            String countryName = entry.getKey();
+            if (normalizedCity.equals(countryName)
+                    || normalizedCity.startsWith(countryName)
+                    || normalizedCity.endsWith(countryName)) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    private String fieldIdForCityClass(String cityClass) {
+        if ("一类城市".equals(cityClass)) {
+            return FIRST_CITY_FIELD_ID;
+        }
+        if ("二类城市".equals(cityClass)) {
+            return SECOND_CITY_FIELD_ID;
+        }
+        return THIRD_CITY_FIELD_ID;
+    }
+
+    private static Map<String, String> createInternationalCityCountryCodes() {
+        Map<String, String> cities = new HashMap<String, String>();
+        addCities(cities, "US", "纽约", "纽约市", "New York", "New York City", "洛杉矶", "Los Angeles",
+                "旧金山", "San Francisco", "华盛顿", "Washington", "芝加哥", "Chicago", "西雅图", "Seattle",
+                "波士顿", "Boston", "休斯敦", "Houston", "拉斯维加斯", "Las Vegas", "达拉斯", "Dallas",
+                "费城", "Philadelphia", "亚特兰大", "Atlanta", "迈阿密", "Miami", "圣地亚哥", "San Diego");
+        addCities(cities, "CA", "多伦多", "Toronto", "温哥华", "Vancouver", "蒙特利尔", "Montreal",
+                "渥太华", "Ottawa", "卡尔加里", "Calgary");
+        addCities(cities, "GB", "伦敦", "London", "曼彻斯特", "Manchester", "伯明翰", "Birmingham",
+                "爱丁堡", "Edinburgh", "格拉斯哥", "Glasgow", "利物浦", "Liverpool");
+        addCities(cities, "FR", "巴黎", "Paris", "里昂", "Lyon", "马赛", "Marseille", "尼斯", "Nice", "图卢兹", "Toulouse");
+        addCities(cities, "DE", "柏林", "Berlin", "法兰克福", "Frankfurt", "慕尼黑", "Munich", "汉堡", "Hamburg",
+                "科隆", "Cologne", "杜塞尔多夫", "Dusseldorf", "Düsseldorf");
+        addCities(cities, "IT", "罗马", "Rome", "米兰", "Milan", "佛罗伦萨", "Florence", "威尼斯", "Venice", "那不勒斯", "Naples");
+        addCities(cities, "JP", "东京", "Tokyo", "大阪", "Osaka", "京都", "Kyoto", "名古屋", "Nagoya", "横滨", "Yokohama",
+                "札幌", "Sapporo", "福冈", "Fukuoka");
+        addCities(cities, "AU", "悉尼", "Sydney", "墨尔本", "Melbourne", "布里斯班", "Brisbane", "堪培拉", "Canberra",
+                "珀斯", "Perth", "阿德莱德", "Adelaide", "黄金海岸", "Gold Coast");
+        addCities(cities, "SG", "新加坡", "Singapore");
+        addCities(cities, "KR", "首尔", "Seoul", "釜山", "Busan");
+        addCities(cities, "TH", "曼谷", "Bangkok", "清迈", "Chiang Mai", "普吉", "Phuket");
+        addCities(cities, "MY", "吉隆坡", "Kuala Lumpur", "槟城", "Penang");
+        addCities(cities, "VN", "河内", "Hanoi", "胡志明市", "Ho Chi Minh City", "岘港", "Da Nang");
+        addCities(cities, "ID", "雅加达", "Jakarta", "巴厘岛", "Bali");
+        addCities(cities, "PH", "马尼拉", "Manila");
+        addCities(cities, "AE", "迪拜", "Dubai", "阿布扎比", "Abu Dhabi");
+        addCities(cities, "IN", "新德里", "New Delhi", "孟买", "Mumbai", "班加罗尔", "Bangalore");
+        addCities(cities, "ES", "马德里", "Madrid", "巴塞罗那", "Barcelona");
+        addCities(cities, "CH", "苏黎世", "Zurich", "Zürich", "日内瓦", "Geneva");
+        addCities(cities, "NL", "阿姆斯特丹", "Amsterdam");
+        addCities(cities, "BR", "圣保罗", "Sao Paulo", "São Paulo", "里约热内卢", "Rio de Janeiro");
+        addCities(cities, "MX", "墨西哥城", "Mexico City");
+        addCities(cities, "NZ", "奥克兰", "Auckland", "惠灵顿", "Wellington");
+        return Collections.unmodifiableMap(cities);
+    }
+
+    private static void addCities(Map<String, String> cities, String countryCode, String... cityNames) {
+        for (String cityName : cityNames) {
+            cities.put(normalizeLocation(cityName), countryCode);
+        }
+    }
+
+    private static Map<String, String> createIsoCountryCodes() {
+        Map<String, String> countries = new LinkedHashMap<String, String>();
+        for (String countryCode : Locale.getISOCountries()) {
+            Locale locale = new Locale("", countryCode);
+            addCountryName(countries, locale.getDisplayCountry(Locale.SIMPLIFIED_CHINESE), countryCode);
+            addCountryName(countries, locale.getDisplayCountry(Locale.ENGLISH), countryCode);
+            addCountryName(countries, countryCode, countryCode);
+        }
+        // JDK 版本与部署语言环境可能对“中国”的展示名称不同,固定补充常用别名。
+        addCountryName(countries, "中国", "CN");
+        addCountryName(countries, "中国大陆", "CN");
+        return Collections.unmodifiableMap(countries);
+    }
+
+    private static void addCountryName(Map<String, String> countries, String countryName, String countryCode) {
+        if (countryName != null && !countryName.trim().isEmpty()) {
+            countries.put(normalizeLocation(countryName), countryCode);
+        }
+    }
+
+    private String displayCountry(String countryCode) {
+        return new Locale("", countryCode).getDisplayCountry(Locale.SIMPLIFIED_CHINESE);
+    }
+
+    private String createJobGradeSearchFieldJson(BigDecimal jobGrade) {
+        Map<String, Object> condition = new LinkedHashMap<String, Object>();
+        condition.put("key", JOB_GRADE_FIELD_ID);
+        // 宜搭数值字段使用闭区间查询,起止值相同即为职等精确匹配。
+        condition.put("value", Arrays.asList(jobGrade, jobGrade));
+        condition.put("type", "DOUBLE");
+        condition.put("operator", "between");
+        condition.put("componentName", "NumberField");
+        try {
+            return objectMapper.writeValueAsString(Collections.singletonList(condition));
+        } catch (JsonProcessingException exception) {
+            throw new IllegalStateException("Unable to create Yida travel standard search condition", exception);
+        }
+    }
+
+    private static Map<String, FlightOption> createFlightOptions() {
+        Map<String, FlightOption> options = new LinkedHashMap<String, FlightOption>();
+        options.put("DOMESTIC_ONE_WAY", new FlightOption("国内机票-单程", DOMESTIC_ONE_WAY_FIELD_ID));
+        options.put("DOMESTIC_ROUND_TRIP", new FlightOption("国内机票-往返", DOMESTIC_ROUND_TRIP_FIELD_ID));
+        options.put("SOUTHEAST_ASIA_ONE_WAY", new FlightOption("东南亚机票-单程", SOUTHEAST_ASIA_ONE_WAY_FIELD_ID));
+        options.put("SOUTHEAST_ASIA_ROUND_TRIP", new FlightOption("东南亚机票-往返", SOUTHEAST_ASIA_ROUND_TRIP_FIELD_ID));
+        options.put("EUROPE_AMERICA_OTHER_ONE_WAY", new FlightOption("欧美及其他机票-单程",
+                EUROPE_AMERICA_OTHER_ONE_WAY_FIELD_ID));
+        options.put("EUROPE_AMERICA_OTHER_ROUND_TRIP", new FlightOption("欧美及其他机票-往返",
+                EUROPE_AMERICA_OTHER_ROUND_TRIP_FIELD_ID));
+        return Collections.unmodifiableMap(options);
+    }
+
+    private String flightOptionOf(String travelRegion, String ticketType) {
+        String regionCode;
+        if ("国内".equals(travelRegion)) {
+            regionCode = "DOMESTIC";
+        } else if ("东南亚".equals(travelRegion)) {
+            regionCode = "SOUTHEAST_ASIA";
+        } else if ("欧美及其它".equals(travelRegion)) {
+            regionCode = "EUROPE_AMERICA_OTHER";
+        } else {
+            return null;
+        }
+
+        if ("单程".equals(ticketType)) {
+            return regionCode + "_ONE_WAY";
+        }
+        if ("往返".equals(ticketType)) {
+            return regionCode + "_ROUND_TRIP";
+        }
+        return null;
+    }
+
+    private void validateConfiguration() {
+        requireText(masterDataConfiguration.getAppType(), "YIDA_MASTER_DATA_APP_TYPE is not configured");
+        requireText(masterDataConfiguration.getSystemToken(), "YIDA_MASTER_DATA_SYSTEM_TOKEN is not configured");
+    }
+
+    private BigDecimal parseNumber(String value, String message) {
+        BigDecimal number = numberOrNull(value);
+        if (number == null) {
+            throw new IllegalArgumentException(message);
+        }
+        return number;
+    }
+
+    private BigDecimal numberOrNull(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.isEmpty()) {
+            return null;
+        }
+        try {
+            return new BigDecimal(text);
+        } catch (NumberFormatException exception) {
+            return null;
+        }
+    }
+
+    private static String normalizeLocation(String value) {
+        String normalized = value.trim().replaceAll("\\s+", "")
+                .replace("(", "").replace(")", "")
+                .replace("(", "").replace(")", "")
+                .replace(",", "").replace(",", "");
+        String[] suffixes = {"特别行政区", "自治区", "省", "市"};
+        for (String suffix : suffixes) {
+            if (normalized.endsWith(suffix)) {
+                return normalized.substring(0, normalized.length() - suffix.length());
+            }
+        }
+        return normalized;
+    }
+
+    private String requireText(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalArgumentException(message);
+        }
+        return value.trim();
+    }
+
+    private String trimToEmpty(String value) {
+        return value == null ? "" : value.trim();
+    }
+
+    private static Set<String> immutableSet(String... values) {
+        return Collections.unmodifiableSet(new LinkedHashSet<String>(Arrays.asList(values)));
+    }
+
+    /**
+     * 机票选项与宜搭主表金额字段的固定映射。
+     */
+    private static class FlightOption {
+
+        private final String name;
+        private final String fieldId;
+
+        private FlightOption(String name, String fieldId) {
+            this.name = name;
+            this.fieldId = fieldId;
+        }
+
+        private String getName() {
+            return name;
+        }
+
+        private String getFieldId() {
+            return fieldId;
+        }
+    }
+
+    /**
+     * 住宿地点分类结果,封装对外分类名称和对应宜搭人民币金额字段。
+     */
+    private static class AccommodationLocation {
+
+        private final String country;
+        private final String cityClass;
+        private final String amountFieldId;
+
+        private AccommodationLocation(String country, String cityClass, String amountFieldId) {
+            this.country = country;
+            this.cityClass = cityClass;
+            this.amountFieldId = amountFieldId;
+        }
+
+        private String getCountry() {
+            return country;
+        }
+
+        private String getCityClass() {
+            return cityClass;
+        }
+
+        private String getAmountFieldId() {
+            return amountFieldId;
+        }
+    }
+}

+ 151 - 0
src/main/java/com/malk/minglei/service/impl/yida/YidaFormInstanceClientImpl.java

@@ -0,0 +1,151 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.service.yida.YidaFormInstanceClient;
+import com.malk.service.dingtalk.DDClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.HttpStatusCodeException;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.net.URI;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 使用宜搭表单实例详情接口读取流程表单数据。
+ */
+@Service
+public class YidaFormInstanceClientImpl implements YidaFormInstanceClient {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(YidaFormInstanceClientImpl.class);
+    private static final String FORM_INSTANCE_ENDPOINT = "https://api.dingtalk.com/v1.0/yida/forms/instances/{formInstanceId}";
+    private static final String ACCESS_TOKEN_HEADER = "x-acs-dingtalk-access-token";
+    private static final String YIDA_PUBLIC_ACCOUNT = "yida_pub_account";
+
+    private final DDClient dingTalkClient;
+    private final RestTemplate dingTalkRestTemplate;
+    private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
+
+    public YidaFormInstanceClientImpl(DDClient dingTalkClient,
+                                      @Qualifier("dingTalkRestTemplate") RestTemplate dingTalkRestTemplate,
+                                      YidaApplicationsProperties applications) {
+        this.dingTalkClient = dingTalkClient;
+        this.dingTalkRestTemplate = dingTalkRestTemplate;
+        this.coordinationOfficeConfiguration = applications.getCoordinationOffice();
+    }
+
+    @Override
+    public Map<String, Object> getFormInstance(String formId, String formInstanceId) {
+        String requestedFormId = requireText(formId, "formId must not be blank");
+        String requestedFormInstanceId = requireText(formInstanceId, "formInstanceId must not be blank");
+
+        String appType = requireText(coordinationOfficeConfiguration.getAppType(),
+                "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        String systemToken = requireText(coordinationOfficeConfiguration.getSystemToken(),
+                "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+        String accessToken = obtainAccessToken(requestedFormId, requestedFormInstanceId);
+        URI endpoint = UriComponentsBuilder.fromUriString(FORM_INSTANCE_ENDPOINT)
+                .buildAndExpand(requestedFormInstanceId)
+                .toUri();
+        URI requestUri = UriComponentsBuilder.fromUri(endpoint)
+                .queryParam("appType", appType)
+                .queryParam("systemToken", systemToken)
+                .queryParam("userId", YIDA_PUBLIC_ACCOUNT)
+                .build()
+                .encode()
+                .toUri();
+
+        LOGGER.info("Yida form instance query request stage=YIDA_FORM_INSTANCE_QUERY method=GET "
+                        + "endpoint=/v1.0/yida/forms/instances/{formInstanceId} formId={} formInstanceId={} "
+                        + "appTypePresent=true systemTokenPresent=true userId={} accessTokenPresent=true",
+                requestedFormId, requestedFormInstanceId, YIDA_PUBLIC_ACCOUNT);
+        ResponseEntity<Map<String, Object>> httpResponse;
+        try {
+            httpResponse = dingTalkRestTemplate.exchange(
+                    requestUri,
+                    HttpMethod.GET,
+                    new HttpEntity<Void>(createHeaders(accessToken)),
+                    new ParameterizedTypeReference<Map<String, Object>>() {
+                    });
+        } catch (HttpStatusCodeException exception) {
+            LOGGER.error("Yida form instance query rejected stage=YIDA_FORM_INSTANCE_QUERY formId={} "
+                            + "formInstanceId={} status={} statusText={} responseBody={}",
+                    requestedFormId, requestedFormInstanceId, exception.getRawStatusCode(),
+                    exception.getStatusText(), sanitizeErrorBody(exception.getResponseBodyAsString(), systemToken, accessToken));
+            throw exception;
+        } catch (RestClientException exception) {
+            LOGGER.error("Yida form instance query transport failure stage=YIDA_FORM_INSTANCE_QUERY formId={} "
+                            + "formInstanceId={} exceptionType={} message={}",
+                    requestedFormId, requestedFormInstanceId, exception.getClass().getSimpleName(), exception.getMessage());
+            throw exception;
+        }
+        Map<String, Object> responseBody = httpResponse.getBody();
+        if (responseBody == null) {
+            throw new IllegalStateException("Yida form instance query returned an empty response");
+        }
+        Map<String, Object> result = new LinkedHashMap<String, Object>(responseBody);
+        LOGGER.info("Yida form instance query response stage=YIDA_FORM_INSTANCE_QUERY formId={} "
+                        + "formInstanceId={} status={} responseKeys={} resultKeys={} formDataPresent={}",
+                requestedFormId, requestedFormInstanceId, httpResponse.getStatusCodeValue(), result.keySet(),
+                nestedKeys(result.get("result")), hasFormData(result));
+        return result;
+    }
+
+    private HttpHeaders createHeaders(String accessToken) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
+        headers.set(ACCESS_TOKEN_HEADER, accessToken);
+        return headers;
+    }
+
+    private String obtainAccessToken(String formId, String formInstanceId) {
+        try {
+            return requireText(dingTalkClient.getAccessToken(), "Unable to obtain DingTalk access token");
+        } catch (RuntimeException exception) {
+            LOGGER.error("Yida form instance query could not obtain DingTalk access token "
+                            + "stage=YIDA_FORM_INSTANCE_QUERY formId={} formInstanceId={} exceptionType={} message={}",
+                    formId, formInstanceId, exception.getClass().getSimpleName(), exception.getMessage());
+            throw exception;
+        }
+    }
+
+    private String sanitizeErrorBody(String responseBody, String systemToken, String accessToken) {
+        if (responseBody == null || responseBody.trim().isEmpty()) {
+            return "<empty>";
+        }
+        String sanitized = responseBody.replace(systemToken, "******").replace(accessToken, "******");
+        return sanitized.length() <= 4096 ? sanitized : sanitized.substring(0, 4096) + "...<truncated>";
+    }
+
+    private Object nestedKeys(Object value) {
+        return value instanceof Map ? ((Map<?, ?>) value).keySet() : Collections.emptySet();
+    }
+
+    private boolean hasFormData(Map<String, Object> responseBody) {
+        if (responseBody.containsKey("formData")) {
+            return true;
+        }
+        Object result = responseBody.get("result");
+        return result instanceof Map && ((Map<?, ?>) result).containsKey("formData");
+    }
+
+    private String requireText(String value, String message) {
+        if (!StringUtils.hasText(value)) {
+            throw new IllegalStateException(message);
+        }
+        return value.trim();
+    }
+}

+ 73 - 0
src/main/java/com/malk/minglei/service/impl/yida/YidaProcessInstanceClientImpl.java

@@ -0,0 +1,73 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.service.approval.DingTalkApprovalClient;
+import com.malk.minglei.service.yida.YidaProcessInstanceClient;
+import com.malk.service.dingtalk.DDClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 通过钉钉开放平台审批接口查询宜搭流程实例详情。
+ */
+@Service
+public class YidaProcessInstanceClientImpl implements YidaProcessInstanceClient {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(YidaProcessInstanceClientImpl.class);
+
+    private final DDClient dingTalkClient;
+    private final DingTalkApprovalClient dingTalkApprovalClient;
+    private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
+
+    public YidaProcessInstanceClientImpl(DDClient dingTalkClient,
+                                         DingTalkApprovalClient dingTalkApprovalClient,
+                                         YidaApplicationsProperties applications) {
+        this.dingTalkClient = dingTalkClient;
+        this.dingTalkApprovalClient = dingTalkApprovalClient;
+        this.coordinationOfficeConfiguration = applications.getCoordinationOffice();
+    }
+
+    @Override
+    public Map<String, Object> getProcessInstance(String processInstanceId) {
+        String instanceId = requireText(processInstanceId, "processInstanceId must not be blank");
+        String accessToken = requireText(dingTalkClient.getAccessToken(), "Unable to obtain DingTalk access token");
+        String appType = requireText(coordinationOfficeConfiguration.getAppType(),
+                "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured");
+        String systemToken = requireText(coordinationOfficeConfiguration.getSystemToken(),
+                "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
+        String userId = requireText(coordinationOfficeConfiguration.getProcessInstanceQueryUserId(),
+                "YIDA_COORDINATION_OFFICE_PROCESS_INSTANCE_QUERY_USER_ID is not configured");
+        LOGGER.info("Yida process instance query request stage=YIDA_PROCESS_INSTANCE_QUERY "
+                        + "method=GET endpoint=/v2.0/yida/processes/instancesInfos/{id} processInstanceId={} "
+                        + "appTypePresent=true systemTokenPresent=true userId={} accessTokenPresent=true",
+                instanceId, userId);
+        try {
+            Map<String, Object> response = dingTalkApprovalClient.getYidaProcessInstanceDetail(
+                    accessToken, instanceId, appType, systemToken, userId);
+            Map<String, Object> result = response == null
+                    ? new LinkedHashMap<String, Object>()
+                    : new LinkedHashMap<String, Object>(response);
+            LOGGER.info("DingTalk process instance query response stage=YIDA_PROCESS_INSTANCE_QUERY "
+                            + "responseKeys={} resultPresent={}",
+                    result.keySet(), result.containsKey("result"));
+            return result;
+        } catch (RuntimeException exception) {
+            LOGGER.error("DingTalk process instance query failed stage=YIDA_PROCESS_INSTANCE_QUERY "
+                            + "processInstanceId={} exceptionType={}",
+                    instanceId, exception.getClass().getSimpleName());
+            throw exception;
+        }
+    }
+
+    private String requireText(String value, String message) {
+        if (!StringUtils.hasText(value)) {
+            throw new IllegalStateException(message);
+        }
+        return value.trim();
+    }
+}

+ 19 - 0
src/main/java/com/malk/minglei/service/port/TravelApplicationPushService.java

@@ -0,0 +1,19 @@
+package com.malk.minglei.service.port;
+
+import com.malk.minglei.dto.TravelApplicationPushRequest;
+
+import java.util.Map;
+
+/**
+ * 将已通过的钉钉 OA 出差审批数据发起为宜搭出差申请流程。
+ */
+public interface TravelApplicationPushService {
+
+    /**
+     * 转换 OA 主表和子表数据并发起宜搭流程。
+     *
+     * @param request OA 连接器提交的出差申请数据
+     * @return OA 与宜搭流程实例的关联结果
+     */
+    Map<String, Object> push(TravelApplicationPushRequest request);
+}

+ 46 - 0
src/main/java/com/malk/minglei/service/port/TravelStandardPortService.java

@@ -0,0 +1,46 @@
+package com.malk.minglei.service.port;
+
+import java.math.BigDecimal;
+import java.util.Map;
+
+/**
+ * 差旅住宿和机票标准查询服务。
+ */
+public interface TravelStandardPortService {
+
+    /**
+     * 根据员工职等和国内或国外出差城市返回对应的住宿标准。
+     *
+     * @param userId 钉钉用户 ID
+     * @param city 出差城市
+     * @return 当前员工命中的差旅住宿标准
+     */
+    Map<String, Object> queryTravelStandards(String userId, String city);
+
+    /**
+     * 根据员工职等和机票选项返回对应的机票报销标准。
+     *
+     * @param userId 钉钉用户 ID
+     * @param travelRegion 出差区域编码
+     * @param ticketType 机票类型
+     * @return 当前员工命中的机票标准
+     */
+    default Map<String, Object> queryFlightStandard(String userId, String travelRegion, String ticketType) {
+        throw new UnsupportedOperationException("Flight standard query is not supported");
+    }
+
+    /**
+     * 根据员工职等、机票选项、次数和预算金额返回机票标准及是否超标。
+     *
+     * @param userId 钉钉用户 ID
+     * @param travelRegion 出差区域编码
+     * @param ticketType 机票类型
+     * @param tripCount 机票次数
+     * @param budgetAmount 预算金额
+     * @return 当前员工命中的机票标准及超标状态
+     */
+    default Map<String, Object> queryFlightStandard(String userId, String travelRegion, String ticketType,
+                                                     Integer tripCount, BigDecimal budgetAmount) {
+        return queryFlightStandard(userId, travelRegion, ticketType);
+    }
+}

+ 18 - 0
src/main/java/com/malk/minglei/service/yida/YidaFormInstanceClient.java

@@ -0,0 +1,18 @@
+package com.malk.minglei.service.yida;
+
+import java.util.Map;
+
+/**
+ * 宜搭普通表单实例查询客户端。
+ */
+public interface YidaFormInstanceClient {
+
+    /**
+     * 按表单实例 ID 查询表单数据,并校验返回记录所属表单。
+     *
+     * @param formId 宜搭表单 ID
+     * @param formInstanceId 宜搭表单实例 ID
+     * @return 宜搭表单实例详情
+     */
+    Map<String, Object> getFormInstance(String formId, String formInstanceId);
+}

+ 18 - 0
src/main/java/com/malk/minglei/service/yida/YidaProcessInstanceClient.java

@@ -0,0 +1,18 @@
+package com.malk.minglei.service.yida;
+
+import java.util.Map;
+
+/**
+ * 查询宜搭流程对应的钉钉审批实例详情的客户端。
+ */
+public interface YidaProcessInstanceClient {
+
+    /**
+     * 按流程实例 ID 查询钉钉审批实例详情。
+     *
+     * @param processInstanceId 宜搭流程实例 ID
+     * @return 钉钉开放平台接口原始响应
+     */
+    Map<String, Object> getProcessInstance(String processInstanceId);
+
+}

+ 42 - 0
src/test/java/com/malk/minglei/config/BankingConfigurationTest.java

@@ -0,0 +1,42 @@
+package com.malk.minglei.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.http.converter.StringHttpMessageConverter;
+import org.springframework.web.client.RestTemplate;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 验证银企直连 HTTP 客户端的响应解码配置。
+ */
+class BankingConfigurationTest {
+
+    /**
+     * 验证默认环境隔离:常规银企接口使用测试地址,回单下载使用正式地址。
+     */
+    @Test
+    void separatesSandboxApisFromProductionReceiptDownload() {
+        BankingProperties properties = new BankingProperties();
+
+        assertEquals("https://sandbox.nexbankapi.com", properties.getBaseUrl());
+        assertEquals("https://open.nexbankapi.com", properties.getReceiptBaseUrl());
+    }
+
+    /**
+     * NexBank 未声明响应字符集时,必须将 UTF-8 中文错误信息正确解码。
+     */
+    @Test
+    void usesUtf8ForStringResponsesWithoutCharset() {
+        RestTemplate restTemplate = new BankingConfiguration().bankingRestTemplate(new BankingProperties());
+
+        StringHttpMessageConverter converter = restTemplate.getMessageConverters().stream()
+                .filter(StringHttpMessageConverter.class::isInstance)
+                .map(StringHttpMessageConverter.class::cast)
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("缺少字符串响应转换器"));
+
+        assertEquals(StandardCharsets.UTF_8, converter.getDefaultCharset());
+    }
+}

+ 110 - 0
src/test/java/com/malk/minglei/controller/BankingControllerTest.java

@@ -0,0 +1,110 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.config.BankingProperties;
+import com.malk.minglei.dto.banking.BankBranchQueryRequest;
+import com.malk.minglei.dto.banking.BankTransactionQueryRequest;
+import com.malk.minglei.dto.banking.CurrentBalanceQueryRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadResult;
+import com.malk.minglei.dto.banking.SinglePaymentRequest;
+import com.malk.minglei.service.banking.BankingService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 验证银企直连控制器将五类请求委托给对应的服务层方法。
+ */
+class BankingControllerTest {
+
+    /**
+     * 验证查询与支付接口均返回统一成功响应,并传递原始入参。
+     */
+    @Test
+    void delegatesAllBankingRequestsToService() {
+        BankingService bankingService = mock(BankingService.class);
+        BankingController controller = new BankingController(bankingService, new BankingProperties());
+        Map<String, Object> result = Collections.<String, Object>singletonMap("code", "200");
+
+        BankTransactionQueryRequest transactionRequest = new BankTransactionQueryRequest();
+        transactionRequest.setAccountNo("account-1");
+        CurrentBalanceQueryRequest balanceRequest = new CurrentBalanceQueryRequest();
+        balanceRequest.setAccountNo("account-1");
+        SinglePaymentRequest paymentRequest = new SinglePaymentRequest();
+        paymentRequest.setBusinessTransNo("PAY-1");
+        paymentRequest.setPayAmount(new BigDecimal("1.00"));
+        BankBranchQueryRequest branchRequest = new BankBranchQueryRequest();
+        branchRequest.setName("测试支行");
+        ElectronicBillDownloadRequest electronicBillRequest = new ElectronicBillDownloadRequest();
+        electronicBillRequest.setUniqueNumber("receipt-1");
+        ElectronicBillDownloadResult electronicBillResult = new ElectronicBillDownloadResult(
+                "receipt.pdf", "/minglei/pay_file/receipt.pdf", 100L, "application/pdf");
+        Map<String, Object> combinationResult = new LinkedHashMap<String, Object>();
+        combinationResult.put("submitResponse", result);
+        combinationResult.put("paymentStatus", result);
+        combinationResult.put("electronicBill", electronicBillResult);
+
+        given(bankingService.queryTransactions(same(transactionRequest))).willReturn(result);
+        given(bankingService.queryCurrentBalance(same(balanceRequest))).willReturn(result);
+        given(bankingService.submitSinglePayment(same(paymentRequest))).willReturn(result);
+        given(bankingService.submitSinglePaymentAndQueryStatus(same(paymentRequest))).willReturn(combinationResult);
+        given(bankingService.queryPaymentStatus("PAY-1")).willReturn(result);
+        given(bankingService.searchBankBranches(same(branchRequest))).willReturn(result);
+        given(bankingService.downloadElectronicBill(same(electronicBillRequest))).willReturn(electronicBillResult);
+
+        assertSuccess(controller.queryTransactions(transactionRequest), result);
+        assertSuccess(controller.queryCurrentBalance(balanceRequest), result);
+        assertSuccess(controller.submitSinglePayment(paymentRequest), result);
+        ResponseEntity<ApiResponse<Map<String, Object>>> combinationResponse =
+                controller.submitSinglePaymentAndQueryStatus(paymentRequest);
+        assertEquals(HttpStatus.OK, combinationResponse.getStatusCode());
+        ElectronicBillDownloadResult combinationBill = (ElectronicBillDownloadResult) combinationResponse.getBody()
+                .getResult().get("electronicBill");
+        assertEquals("http://www.minglei.online:55580/api/banking/electronic-bills/files/receipt.pdf?option=preview", combinationBill.getPreviewUrl());
+        assertEquals("http://www.minglei.online:55580/api/banking/electronic-bills/files/receipt.pdf?option=download", combinationBill.getDownloadUrl());
+        assertSuccess(controller.queryPaymentStatus("PAY-1"), result);
+        assertSuccess(controller.queryPaymentStatusByQuery("PAY-1"), result);
+        assertSuccess(controller.searchBankBranches(branchRequest), result);
+        ResponseEntity<ApiResponse<ElectronicBillDownloadResult>> electronicBillResponse =
+                controller.downloadElectronicBill(electronicBillRequest);
+        assertEquals(HttpStatus.OK, electronicBillResponse.getStatusCode());
+        assertEquals(electronicBillResult.getFileName(), electronicBillResponse.getBody().getResult().getFileName());
+        assertEquals("http://www.minglei.online:55580/api/banking/electronic-bills/files/receipt.pdf?option=preview",
+                electronicBillResponse.getBody().getResult().getPreviewUrl());
+        assertEquals("http://www.minglei.online:55580/api/banking/electronic-bills/files/receipt.pdf?option=download",
+                electronicBillResponse.getBody().getResult().getDownloadUrl());
+
+        verify(bankingService).queryTransactions(same(transactionRequest));
+        verify(bankingService).queryCurrentBalance(same(balanceRequest));
+        verify(bankingService).submitSinglePayment(same(paymentRequest));
+        verify(bankingService).submitSinglePaymentAndQueryStatus(same(paymentRequest));
+        verify(bankingService, times(2)).queryPaymentStatus("PAY-1");
+        verify(bankingService).searchBankBranches(same(branchRequest));
+        verify(bankingService).downloadElectronicBill(same(electronicBillRequest));
+    }
+
+    /**
+     * 验证成功响应的状态与数据包装。
+     *
+     * @param response 控制器响应
+     * @param expectedResult 预期业务结果
+     */
+    private void assertSuccess(ResponseEntity<ApiResponse<Map<String, Object>>> response,
+                               Map<String, Object> expectedResult) {
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals(expectedResult, response.getBody().getResult());
+    }
+}

+ 45 - 0
src/test/java/com/malk/minglei/controller/DictionaryControllerTest.java

@@ -0,0 +1,45 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.service.dictionary.DictionaryQueryService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 数据字典控制器测试。
+ */
+class DictionaryControllerTest {
+
+    /**
+     * 验证控制器将字典编码与筛选条件完整委托给服务层。
+     */
+    @Test
+    void queryDelegatesDictionaryCodeAndFiltersToService() {
+        DictionaryQueryService dictionaryQueryService = mock(DictionaryQueryService.class);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("t100id", "ML");
+        filters.put("ent", "明磊");
+        Map<String, Object> pageResult = new LinkedHashMap<String, Object>();
+        pageResult.put("page", Integer.valueOf(1));
+        pageResult.put("pageSize", Integer.valueOf(100));
+        pageResult.put("total", Long.valueOf(1L));
+        given(dictionaryQueryService.query("erp-supplier", filters)).willReturn(pageResult);
+
+        DictionaryController controller = new DictionaryController(dictionaryQueryService);
+        ResponseEntity<ApiResponse<Map<String, Object>>> response = controller.query("erp-supplier", filters);
+
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals(pageResult, response.getBody().getResult());
+        verify(dictionaryQueryService).query(eq("erp-supplier"), eq(filters));
+    }
+}

+ 88 - 0
src/test/java/com/malk/minglei/controller/ExpenseReimbursementControllerTest.java

@@ -0,0 +1,88 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.service.port.TravelStandardPortService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.math.BigDecimal;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 费用报销标准接口控制器测试。
+ */
+class ExpenseReimbursementControllerTest {
+
+    /**
+     * 验证机票查询请求会将 JSON 字段完整传递给服务层,并返回统一成功响应。
+     */
+    @Test
+    void resolvesFlightStandardFromRequestBody() throws Exception {
+        TravelStandardPortService service = new TravelStandardPortService() {
+            @Override
+            public Map<String, Object> queryTravelStandards(String userId, String city) {
+                throw new UnsupportedOperationException("Not used by this test");
+            }
+
+            @Override
+            public Map<String, Object> queryFlightStandard(String userId, String travelRegion, String ticketType,
+                                                           Integer tripCount, BigDecimal budgetAmount) {
+                assertEquals("728378541", userId);
+                assertEquals("东南亚", travelRegion);
+                assertEquals("往返", ticketType);
+                assertEquals(Integer.valueOf(3), tripCount);
+                assertEquals(new BigDecimal("13000"), budgetAmount);
+                Map<String, Object> result = new LinkedHashMap<String, Object>();
+                result.put("limitAmount", 4000);
+                result.put("isOverStandard", "是");
+                result.put("currency", "CNY");
+                return result;
+            }
+        };
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new ExpenseReimbursementController(service)).build();
+
+        mockMvc.perform(post("/api/expense-reimbursement/flight-standards/resolve")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":\"728378541\",\"travelRegion\":\"东南亚\","
+                                + "\"ticketType\":\"往返\",\"tripCount\":3,\"budgetAmount\":13000}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.isSuccess").value(true))
+                .andExpect(jsonPath("$.result.limitAmount").value(4000))
+                .andExpect(jsonPath("$.result.isOverStandard").value("是"))
+                .andExpect(jsonPath("$.result.currency").value("CNY"));
+    }
+
+    /**
+     * 验证请求体缺少机票类型字段时由参数校验拒绝请求。
+     */
+    @Test
+    void acceptsRequestWithoutTicketType() throws Exception {
+        TravelStandardPortService service = new TravelStandardPortService() {
+            @Override
+            public Map<String, Object> queryTravelStandards(String userId, String city) {
+                return new LinkedHashMap<String, Object>();
+            }
+
+            @Override
+            public Map<String, Object> queryFlightStandard(String userId, String travelRegion, String ticketType) {
+                assertNull(ticketType);
+                return new LinkedHashMap<String, Object>();
+            }
+        };
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new ExpenseReimbursementController(service)).build();
+
+        mockMvc.perform(post("/api/expense-reimbursement/flight-standards/resolve")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":\"728378541\",\"travelRegion\":\"东南亚\"}"))
+                .andExpect(status().isOk());
+    }
+}

+ 26 - 0
src/test/java/com/malk/minglei/controller/GlobalExceptionHandlerTest.java

@@ -0,0 +1,26 @@
+package com.malk.minglei.controller;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class GlobalExceptionHandlerTest {
+
+    @Test
+    void returnsUnexpectedExceptionDetailWithoutExposingSystemToken() {
+        RuntimeException exception = new RuntimeException("request failed: systemToken=secret-token");
+
+        ResponseEntity<Map<String, String>> response = new GlobalExceptionHandler().handleUnexpected(exception);
+
+        assertEquals(HttpStatus.BAD_GATEWAY, response.getStatusCode());
+        assertEquals("YIDA_CALL_FAILED", response.getBody().get("code"));
+        assertEquals(RuntimeException.class.getName(), response.getBody().get("exceptionType"));
+        assertTrue(response.getBody().get("message").contains("systemToken=******"));
+        assertTrue(!response.getBody().get("message").contains("secret-token"));
+    }
+}

+ 81 - 0
src/test/java/com/malk/minglei/controller/MingleiPortControllerTest.java

@@ -0,0 +1,81 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.service.port.SupplierPriceApprovalProcessService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class MingleiPortControllerTest {
+
+    @Test
+    void acceptsRequestWithoutOptionalProcessInstanceId() throws Exception {
+        SupplierPriceApprovalProcessService service = (formId, formInstanceId, processInstanceId) -> {
+            assertEquals("FORM-001", formId);
+            assertEquals("form-instance-001", formInstanceId);
+            assertNull(processInstanceId);
+            return Collections.<String, Object>singletonMap("srvcode", "000");
+        };
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new MingleiPortController(service)).build();
+
+        mockMvc.perform(post("/api/minglei/ports/supplier-price-approvals")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"formId\":\"FORM-001\",\"formInstanceId\":\"form-instance-001\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.srvcode").value("000"));
+    }
+
+    @Test
+    void forwardsSeparateProcessAndFormInstanceIds() throws Exception {
+        SupplierPriceApprovalProcessService service = (formId, formInstanceId, processInstanceId) -> {
+            assertEquals("FORM-001", formId);
+            assertEquals("form-instance-001", formInstanceId);
+            assertEquals("process-instance-001", processInstanceId);
+            return Collections.<String, Object>singletonMap("srvcode", "000");
+        };
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new MingleiPortController(service)).build();
+
+        mockMvc.perform(post("/api/minglei/ports/supplier-price-approvals")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"formId\":\"FORM-001\",\"formInstanceId\":\"form-instance-001\","
+                                + "\"processInstanceId\":\"process-instance-001\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.srvcode").value("000"));
+    }
+
+    @Test
+    void rejectsPriceApprovalRequestWithoutProcessInstanceId() throws Exception {
+        SupplierPriceApprovalProcessService service = (formId, formInstanceId, processInstanceId) ->
+                Collections.<String, Object>emptyMap();
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new MingleiPortController(service)).build();
+
+        mockMvc.perform(post("/api/minglei/ports/supplier-price-approvals")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"formId\":\"FORM-468B6FB2D5BF490A8BC6DBD6FAF9BCCA28R9\","
+                                + "\"formInstanceId\":\"form-instance-001\"}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(
+                        "Request validation failed: processInstanceId must not be blank for supplier price approval forms"));
+    }
+
+    @Test
+    void identifiesMissingRequiredRequestField() throws Exception {
+        SupplierPriceApprovalProcessService service = (formId, formInstanceId, processInstanceId) ->
+                Collections.<String, Object>emptyMap();
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new MingleiPortController(service)).build();
+
+        mockMvc.perform(post("/api/minglei/ports/supplier-price-approvals")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"formId\":\"FORM-001\"}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value("Request validation failed: formInstanceId must not be blank"));
+    }
+}

+ 62 - 0
src/test/java/com/malk/minglei/controller/TravelApplicationPushControllerTest.java

@@ -0,0 +1,62 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.TravelApplicationPushRequest;
+import com.malk.minglei.service.port.TravelApplicationPushService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 出差申请 OA 推送接口控制器测试。
+ */
+class TravelApplicationPushControllerTest {
+
+    /**
+     * 验证连接器传入的 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");
+            return result;
+        };
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TravelApplicationPushController(service)).build();
+
+        mockMvc.perform(post("/api/minglei/ports/travel-applications")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"oaProcessInstanceId\":\"oa-process-001\",\"userId\":\"user-001\","
+                                + "\"departmentId\":\"123\",\"title\":\"北京客户拜访\"}"))
+                .andExpect(status().isCreated())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.isSuccess").value(true))
+                .andExpect(jsonPath("$.result.yidaProcessInstanceId").value("yida-process-001"));
+    }
+
+    /**
+     * 验证缺少连接器必须提供的 OA 审批实例 ID 时拒绝请求。
+     */
+    @Test
+    void rejectsRequestWithoutOaProcessInstanceId() throws Exception {
+        TravelApplicationPushService service = request -> new LinkedHashMap<String, Object>();
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TravelApplicationPushController(service)).build();
+
+        mockMvc.perform(post("/api/minglei/ports/travel-applications")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":\"user-001\",\"departmentId\":\"123\",\"title\":\"北京客户拜访\"}"))
+                .andExpect(status().isBadRequest());
+    }
+}

+ 60 - 0
src/test/java/com/malk/minglei/controller/TravelStandardPortControllerTest.java

@@ -0,0 +1,60 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.service.port.TravelStandardPortService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 差旅标准接口控制器测试。
+ */
+class TravelStandardPortControllerTest {
+
+    /**
+     * 验证 userId 和城市会从 POST 请求体传递给服务层,并按统一响应结构返回。
+     */
+    @Test
+    void forwardsRequiredUserIdAndCity() throws Exception {
+        TravelStandardPortService service = (userId, city) -> {
+            assertEquals("user-001", userId);
+            assertEquals("海口", city);
+            Map<String, Object> result = new LinkedHashMap<String, Object>();
+            result.put("cityClass", "二类城市");
+            result.put("amount", 400);
+            return result;
+        };
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TravelStandardPortController(service)).build();
+
+        mockMvc.perform(post("/api/minglei/ports/travel-standards")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userID\":\"user-001\",\"city\":\"海口\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.isSuccess").value(true))
+                .andExpect(jsonPath("$.result.cityClass").value("二类城市"));
+    }
+
+    /**
+     * 验证请求体缺少 userId 时由 Spring MVC 拒绝请求。
+     */
+    @Test
+    void rejectsRequestWithoutUserId() throws Exception {
+        TravelStandardPortService service = (userId, city) -> Collections.emptyMap();
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TravelStandardPortController(service)).build();
+
+        mockMvc.perform(post("/api/minglei/ports/travel-standards")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"city\":\"海口\"}"))
+                .andExpect(status().isBadRequest());
+    }
+}

+ 390 - 0
src/test/java/com/malk/minglei/service/impl/banking/BankingServiceImplTest.java

@@ -0,0 +1,390 @@
+package com.malk.minglei.service.impl.banking;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.BankingProperties;
+import com.malk.minglei.dto.banking.BankBranchQueryRequest;
+import com.malk.minglei.dto.banking.BankTransactionQueryRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadRequest;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadResult;
+import com.malk.minglei.dto.banking.SinglePaymentRequest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.never;
+
+/**
+ * 验证流水查询的日期筛选条件会完整转发给银行。
+ */
+class BankingServiceImplTest {
+
+    @TempDir
+    Path temporaryDirectory;
+
+    @Test
+    void forwardsBothAccountingAndUpdateDateRanges() {
+        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");
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper());
+        BankTransactionQueryRequest request = new BankTransactionQueryRequest();
+        request.setAccountNo("account-1");
+        request.setFromDateId("20261001");
+        request.setToDateId("20261016");
+        request.setFromUpdatedTime("2026-10-01 00:00:00");
+        request.setToUpdatedTime("2026-10-16 23:59:59");
+
+        given(restTemplate.exchange(eq("https://bank.example/api/v2/bs/list"), eq(HttpMethod.POST),
+                any(HttpEntity.class), eq(String.class)))
+                .willReturn(new ResponseEntity<String>("{\"code\":\"200\"}", HttpStatus.OK));
+
+        service.queryTransactions(request);
+
+        ArgumentCaptor<HttpEntity> entityCaptor = ArgumentCaptor.forClass(HttpEntity.class);
+        verify(restTemplate).exchange(eq("https://bank.example/api/v2/bs/list"), eq(HttpMethod.POST),
+                entityCaptor.capture(), eq(String.class));
+        Map<String, Object> body = (Map<String, Object>) entityCaptor.getValue().getBody();
+        assertEquals("20261001", body.get("fromDateId"));
+        assertEquals("20261016", body.get("toDateId"));
+        assertEquals("2026-10-01 00:00:00", body.get("fromUpdatedTime"));
+        assertEquals("2026-10-16 23:59:59", body.get("toUpdatedTime"));
+    }
+
+    @Test
+    void doesNotSendContentTypeForBranchQueryGetRequest() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        BankingProperties properties = properties();
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper());
+        BankBranchQueryRequest request = new BankBranchQueryRequest();
+        request.setQueryType("full");
+        request.setStartUpdateTime("2024-01-01 00:00:00");
+
+        URI expectedUri = URI.create("https://bank.example/api/v2/bank/searchBranch?queryType=full"
+                + "&startUpdateTime=2024-01-01+00%3A00%3A00");
+        given(restTemplate.exchange(any(URI.class),
+                eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
+                .willReturn(new ResponseEntity<String>("{\"code\":\"200\"}", HttpStatus.OK));
+
+        service.searchBankBranches(request);
+
+        ArgumentCaptor<HttpEntity> entityCaptor = ArgumentCaptor.forClass(HttpEntity.class);
+        ArgumentCaptor<URI> uriCaptor = ArgumentCaptor.forClass(URI.class);
+        verify(restTemplate).exchange(uriCaptor.capture(),
+                eq(HttpMethod.GET), entityCaptor.capture(), eq(String.class));
+        assertEquals(expectedUri, uriCaptor.getValue());
+        assertNull(entityCaptor.getValue().getHeaders().getContentType());
+    }
+
+    /**
+     * 验证省略“股份有限公司”的开户行名称可按总行和支行名称筛选出正确支行。
+     */
+    @Test
+    void matchesAbbreviatedBankBranchNameByBankAndBranchTokens() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties(), new ObjectMapper());
+        BankBranchQueryRequest request = new BankBranchQueryRequest();
+        request.setQueryType("full");
+        request.setName("中国银行宁波中山西路支行");
+        given(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
+                .willReturn(new ResponseEntity<String>("{\"code\":\"200\",\"data\":{\"bankBranchList\":["
+                        + "{\"name\":\"中国银行股份有限公司宁波中山西路支行\",\"headName\":\"中国银行\",\"code\":\"104\"},"
+                        + "{\"name\":\"中国工商银行股份有限公司宁波中山西路支行\",\"headName\":\"中国工商银行\",\"code\":\"102\"}]}}", HttpStatus.OK));
+
+        Map<String, Object> result = service.searchBankBranches(request);
+
+        List<?> bankBranches = (List<?>) ((Map<?, ?>) result.get("data")).get("bankBranchList");
+        assertEquals(1, bankBranches.size());
+        assertEquals("104", ((Map<?, ?>) bankBranches.get(0)).get("code"));
+    }
+
+    /**
+     * 验证不符合 NexBank 指定格式的更新时间不会被转发为上游的参数绑定异常。
+     */
+    @Test
+    void rejectsInvalidBranchUpdateTimeBeforeCallingUpstream() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties(), new ObjectMapper());
+        BankBranchQueryRequest request = new BankBranchQueryRequest();
+        request.setQueryType("full");
+        request.setStartUpdateTime("2024-01-01");
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> service.searchBankBranches(request));
+
+        assertEquals("startUpdateTime 必须使用 yyyy-MM-dd HH:mm:ss 格式", exception.getMessage());
+    }
+
+    /**
+     * 验证组合接口先提交制单,再按相同业务流水号查询状态,并保留两次上游响应。
+     */
+    @Test
+    void submitsPaymentBeforeQueryingItsStatus() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        BankingProperties properties = properties();
+        properties.setPaymentEncryptionEnabled(false);
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper());
+        stubBankBranchSearch(restTemplate);
+        SinglePaymentRequest request = new SinglePaymentRequest();
+        request.setBusinessTransNo("PAY-1");
+        request.setPayAmount(new BigDecimal("1.00"));
+        request.setFromAccountNo("from-account");
+        request.setToAccountNo("to-account");
+        request.setToAccountType("0");
+        request.setToBankName("交通银行");
+        URI statusUri = URI.create("https://bank.example/api/v2/payment/transferStatus?businessTransNo=PAY-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));
+        given(restTemplate.exchange(eq(statusUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
+                .willReturn(new ResponseEntity<String>(
+                        "{\"code\":\"200\",\"data\":{\"transferStatus\":\"X5555\"}}", HttpStatus.OK));
+
+        Map<String, Object> result = service.submitSinglePaymentAndQueryStatus(request);
+
+        Map<String, Object> submitResponse = (Map<String, Object>) result.get("submitResponse");
+        Map<String, Object> paymentStatus = (Map<String, Object>) result.get("paymentStatus");
+        assertEquals("200", submitResponse.get("code"));
+        assertEquals("X5555", ((Map<String, Object>) paymentStatus.get("data")).get("transferStatus"));
+        assertEquals(false, result.get("statusQuerySkipped"));
+        org.mockito.InOrder inOrder = inOrder(restTemplate);
+        inOrder.verify(restTemplate).exchange(eq("https://bank.example/api/v2/payment/commonPay"), eq(HttpMethod.POST),
+                any(HttpEntity.class), eq(String.class));
+        inOrder.verify(restTemplate).exchange(eq(statusUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class));
+    }
+
+    /**
+     * 验证制单业务失败时返回制单响应且不继续查询不存在的支付单。
+     */
+    @Test
+    void skipsStatusQueryWhenPaymentSubmissionFails() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        BankingProperties properties = properties();
+        properties.setPaymentEncryptionEnabled(false);
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper());
+        stubBankBranchSearch(restTemplate);
+        SinglePaymentRequest request = new SinglePaymentRequest();
+        request.setBusinessTransNo("PAY-FAILED");
+        request.setPayAmount(new BigDecimal("1.00"));
+        request.setFromAccountNo("from-account");
+        request.setToAccountNo("to-account");
+        request.setToAccountType("0");
+        request.setToBankName("交通银行");
+        URI statusUri = URI.create("https://bank.example/api/v2/payment/transferStatus?businessTransNo=PAY-FAILED");
+        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\":\"400\",\"message\":\"制单失败\",\"successResponse\":false}", HttpStatus.OK));
+
+        Map<String, Object> result = service.submitSinglePaymentAndQueryStatus(request);
+
+        assertEquals("400", ((Map<String, Object>) result.get("submitResponse")).get("code"));
+        assertNull(result.get("paymentStatus"));
+        assertEquals(true, result.get("statusQuerySkipped"));
+        verify(restTemplate, never()).exchange(eq(statusUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class));
+    }
+
+    /**
+     * 验证支付成功后,组合接口会下载回单并将本地回单信息加入响应。
+     */
+    @Test
+    void downloadsElectronicBillAfterPaymentSucceeds() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        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.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");
+        HttpHeaders receiptHeaders = new HttpHeaders();
+        receiptHeaders.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=PAY-SUCCESS.pdf");
+        receiptHeaders.setContentType(org.springframework.http.MediaType.APPLICATION_PDF);
+        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));
+        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));
+        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));
+
+        Map<String, Object> result = service.submitSinglePaymentAndQueryStatus(request);
+
+        assertEquals(false, result.get("receiptDownloadSkipped"));
+        ElectronicBillDownloadResult receipt = (ElectronicBillDownloadResult) result.get("electronicBill");
+        assertEquals("PAY-SUCCESS.pdf", receipt.getFileName());
+        assertTrue(Files.exists(Paths.get(receipt.getFilePath())));
+        verify(restTemplate).exchange(eq(receiptUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class));
+    }
+
+    /**
+     * 验证回单下载使用唯一流水号查询,并将二进制回单保存到配置目录。
+     */
+    @Test
+    void downloadsElectronicBillAndSavesItToConfiguredDirectory() 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-1");
+        HttpHeaders headers = new HttpHeaders();
+        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);
+        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.pdf", result.getFileName());
+        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())),
+                java.nio.charset.StandardCharsets.UTF_8));
+        verify(restTemplate).exchange(eq(expectedUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class));
+    }
+
+    /**
+     * 验证两个回单查询条件同时缺失或同时传入时,不会调用上游接口。
+     */
+    @Test
+    void rejectsInvalidElectronicBillIdentifiersBeforeCallingUpstream() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        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));
+    }
+
+    /**
+     * 验证未传付款账户时会使用银企配置中的默认账号和账户名称。
+     */
+    @Test
+    void usesConfiguredDefaultPayerWhenRequestDoesNotProvideOne() {
+        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();
+        request.setBusinessTransNo("PAY-2");
+        request.setPayAmount(new BigDecimal("1.00"));
+        request.setToAccountNo("to-account");
+        request.setToBankName("交通银行");
+        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"));
+    }
+
+    /**
+     * 验证收款银行与本行配置不匹配时,支付报文会自动标记为跨行。
+     */
+    @Test
+    void marksPaymentAsCrossBankWhenRecipientBankDoesNotMatchConfiguration() {
+        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.setToAccountNo("to-account");
+        request.setToAccountType("0");
+        request.setToBankName("中国工商银行");
+        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("1", body.get("crossBank"));
+    }
+
+    /**
+     * 模拟支行查询返回的联行号,供支付制单前的收款开户行补全逻辑使用。
+     */
+    private void stubBankBranchSearch(RestTemplate restTemplate) {
+        given(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
+                .willReturn(new ResponseEntity<String>("{\"code\":\"200\",\"data\":{\"bankBranchList\":["
+                        + "{\"name\":\"交通银行\",\"code\":\"301\"},"
+                        + "{\"name\":\"中国工商银行\",\"code\":\"102\"}]}}", HttpStatus.OK));
+    }
+
+    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");
+        properties.setIntraBankName("交通银行");
+        return properties;
+    }
+}

+ 741 - 0
src/test/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImplTest.java

@@ -0,0 +1,741 @@
+package com.malk.minglei.service.impl.dictionary;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 数据字典查询服务分页和筛选测试。
+ */
+class DictionaryQueryServiceImplTest {
+
+    /**
+     * 验证供应商名称模糊匹配使用默认分页,并返回总数和记录列表。
+     */
+    @Test
+    void queryErpSupplierUsesFuzzyFilterAndDefaultPage() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(125L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>singletonList(pageRow("PMAAL004", "明磊供应商")));
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("t100id", "ML");
+        filters.put("ent", "明磊");
+        filters.put("pmaal004", "供应商");
+
+        Map<String, Object> result = service.query("erp-supplier", 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("select count(1) from DSDATA.erp_apmt440_pmaauc_v where pmabsite = :t100id and pmaaent = :ent " +
+                "and pmaal004 like :pmaal004", countSql.getValue());
+        assertEquals("%供应商%", countParameters.getValue().getValue("pmaal004"));
+        assertEquals(Long.valueOf(86L), countParameters.getValue().getValue("ent"));
+
+        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("rownum <= :endRow"));
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(125L), result.get("total"));
+        List<Map<String, Object>> records = records(result);
+        assertEquals("明磊供应商", records.get(0).get("PMAAL004"));
+        assertEquals(null, records.get(0).get("DICTIONARY_PAGE_ROW_NUM"));
+    }
+
+    /**
+     * 验证明磊包材 B9 开窗视图的关键字会匹配料件编号、中文品名和规格,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpPackagingB9WindowViewUsesKeywordAcrossThreeFieldsAndFixedPage() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(61L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("t100id", "VN");
+        filters.put("ent", "越南");
+        filters.put("keyword", "电池");
+
+        Map<String, Object> result = service.query("erp-packaging-b9-window-view", 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 (imaf001 like :keyword or imaal003 like :keyword or imaal004 like :keyword)"));
+        assertEquals("%电池%", countParameters.getValue().getValue("keyword"));
+        assertEquals(Long.valueOf(60L), countParameters.getValue().getValue("ent"));
+
+        ArgumentCaptor<SqlParameterSource> pageParameters = ArgumentCaptor.forClass(SqlParameterSource.class);
+        verify(jdbcTemplate).queryForList(anyString(), pageParameters.capture());
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(61L), result.get("total"));
+    }
+
+    /**
+     * 验证包材关键字会匹配编号、名称和中文材质类型。
+     */
+    @Test
+    void queryErpPackagingMaterialUsesKeywordAcrossThreeFieldsAndFixedPage() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(4L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("ent", "明磊");
+        filters.put("keyword", "彩盒");
+
+        Map<String, Object> result = service.query("erp-packaging-material", 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 (pmaauc001 like :keyword or pmaauc002 like :keyword or case pmaauc016"));
+        assertEquals(Long.valueOf(86L), countParameters.getValue().getValue("ent"));
+        assertEquals("%彩盒%", countParameters.getValue().getValue("keyword"));
+
+        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("case pmaauc016 when '1' then '说明书'"));
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(4L), result.get("total"));
+    }
+
+    /**
+     * 验证原单价按材料编号和公司精确查询,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpMaterialOriginalPriceUsesMaterialCodeAndCompany() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(1L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("czbh", "B9-001");
+        filters.put("ent", "明磊");
+
+        Map<String, Object> result = service.query("erp-material-original-price", 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("pmaauc001 = :czbh and pmaaucent = :ent"));
+        assertEquals("B9-001", countParameters.getValue().getValue("czbh"));
+        assertEquals(Long.valueOf(86L), countParameters.getValue().getValue("ent"));
+
+        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 pmaauc002, pmaauc015, pmaauc001, pmaaucent"));
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(1L), result.get("total"));
+    }
+
+    /**
+     * 验证料件视图可按料号或中文品名模糊筛选,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpItemViewUsesKeywordAndFixedPage() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(101L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("ent", "越南");
+        filters.put("keyword", "电池");
+
+        Map<String, Object> result = service.query("erp-item-view", 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 (imaa001 like :keyword or imaal003 like :keyword)"));
+        assertEquals(Long.valueOf(60L), countParameters.getValue().getValue("ent"));
+        assertEquals("%电池%", countParameters.getValue().getValue("keyword"));
+
+        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 * from DSDATA.erp_apmt440_imaauc_v"));
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(101L), result.get("total"));
+    }
+
+    /**
+     * 验证有效料号可按料号或中文品名模糊筛选,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpActiveItemUsesKeywordAcrossItemCodeAndName() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(11L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("keyword", "B9");
+
+        Map<String, Object> result = service.query("erp-active-item", 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 (imaa001 like :keyword or imaal003 like :keyword)"));
+        assertEquals("%B9%", countParameters.getValue().getValue("keyword"));
+
+        ArgumentCaptor<SqlParameterSource> pageParameters = ArgumentCaptor.forClass(SqlParameterSource.class);
+        verify(jdbcTemplate).queryForList(anyString(), pageParameters.capture());
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(11L), result.get("total"));
+    }
+
+    /**
+     * 验证销售订单开窗按据点和订单号模糊查询明磊公司已审核的未交订单,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpSalesOrderUsesSiteAndFixedPage() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(12L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("t100id", "ML");
+        filters.put("keyword", "SO-001");
+
+        Map<String, Object> result = service.query("erp-sales-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("select distinct xmdasite, xmdadocno"));
+        assertEquals(true, countSql.getValue().contains("where xmdaent = 86 and xmdasite = :t100id"));
+        assertEquals(true, countSql.getValue().contains("and xmdastus = 'Y'"));
+        assertEquals(true, countSql.getValue().contains("and xmdadocno like :keyword"));
+        assertEquals("ML", countParameters.getValue().getValue("t100id"));
+        assertEquals("%SO-001%", countParameters.getValue().getValue("keyword"));
+
+        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("to_char(xmdadocdt, 'yyyy-mm-dd') xmdadocdt"));
+        assertEquals(true, pageSql.getValue().contains("left join DSDATA.ooff_t"));
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(12L), result.get("total"));
+    }
+
+    /**
+     * 验证生产令开窗按据点和逗号分隔的拥有者权限查询,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpProductionOrderUsesSiteAndOwnerPermissions() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.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());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("t100id", "ML");
+        filters.put("qx", "OWNER-A, OWNER-B");
+
+        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("ML", countParameters.getValue().getValue("t100id"));
+        assertEquals(Arrays.asList("OWNER-A", "OWNER-B"), countParameters.getValue().getValue("qx"));
+
+        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(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(8L), result.get("total"));
+    }
+
+    /**
+     * 验证禁用供应商视图使用据点和公司参数,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpSupplierDisabledUsesSiteAndCompany() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(5L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("t100id", "ML");
+        filters.put("ent", "明磊");
+        filters.put("keyword", "供应商");
+
+        Map<String, Object> result = service.query("erp-supplier-disabled", 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("DSDATA.ERP_APMT440_PMAAUC_N"));
+        assertEquals(true, countSql.getValue().contains("and pmaal004 like :keyword"));
+        assertEquals("ML", countParameters.getValue().getValue("t100id"));
+        assertEquals(Long.valueOf(86L), countParameters.getValue().getValue("ent"));
+        assertEquals("%供应商%", countParameters.getValue().getValue("keyword"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(5L), result.get("total"));
+    }
+
+    /**
+     * 验证固定条件的有效料号和范本料号字典均可按默认分页查询。
+     */
+    @Test
+    void queryFixedViewDictionariesUseConfiguredSql() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(1L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+
+        service.query("erp-active-item", Collections.<String, String>emptyMap());
+        Map<String, Object> result = service.query("erp-template-item", Collections.<String, String>emptyMap());
+
+        ArgumentCaptor<String> countSql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate, org.mockito.Mockito.times(2))
+                .queryForObject(countSql.capture(), any(SqlParameterSource.class), eq(Long.class));
+        assertEquals(true, countSql.getAllValues().get(0).contains("imafsite = 'ML'"));
+        assertEquals(true, countSql.getAllValues().get(1).contains("left join DSDATA.imaal_t"));
+        assertEquals(100, result.get("pageSize"));
+    }
+
+    /**
+     * 验证范本料号可按料号或中文品名模糊筛选。
+     */
+    @Test
+    void queryErpTemplateItemUsesKeywordAcrossItemCodeAndName() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(2L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("keyword", "B9");
+
+        Map<String, Object> result = service.query("erp-template-item", 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 (imaa001 like :keyword or imaal003 like :keyword)"));
+        assertEquals("%B9%", countParameters.getValue().getValue("keyword"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(2L), result.get("total"));
+    }
+
+    /**
+     * 验证范本料号订单查询将料号参数绑定为前缀匹配值。
+     */
+    @Test
+    void queryErpTemplateOrdersUsesItemPrefix() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(7L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("bgfblh", "B9");
+        filters.put("keyword", "SO-001");
+
+        Map<String, Object> result = service.query("erp-template-orders", 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("xmdcsite = 'ML' and xmdc001 like :bgfblh"));
+        assertEquals(true, countSql.getValue().contains(
+                "xmdc001 like :bgfblh and xmdadocno like :keyword)"));
+        assertEquals("B9%", countParameters.getValue().getValue("bgfblh"));
+        assertEquals("%SO-001%", countParameters.getValue().getValue("keyword"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(7L), result.get("total"));
+    }
+
+    /**
+     * 验证料号上次价格查询使用宜搭主表与子表字段,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpLastPriceUsesFormItemAndDetailItemCode() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(1L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("main.pmdi004", "SUP-001");
+        filters.put("detail_1.ljbh", "B9-001");
+
+        Map<String, Object> result = service.query("erp-last-price", 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("t1.pmdi004 = :mainPmdi004"));
+        assertEquals(true, countSql.getValue().contains("t1.pmdj002 = :detail1Ljbh"));
+        assertEquals("SUP-001", countParameters.getValue().getValue("mainPmdi004"));
+        assertEquals("B9-001", countParameters.getValue().getValue("detail1Ljbh"));
+
+        ArgumentCaptor<SqlParameterSource> pageParameters = ArgumentCaptor.forClass(SqlParameterSource.class);
+        verify(jdbcTemplate).queryForList(anyString(), pageParameters.capture());
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(1L), result.get("total"));
+    }
+
+    /**
+     * 验证已审核未发齐出通单按公司、据点和出通单项次精确查询,并固定返回前 100 条记录。
+     */
+    @Test
+    void queryErpUnfulfilledOrderUsesCompanyAndSite() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(6L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("ent", "明磊");
+        filters.put("site", "ML");
+        filters.put("keyword", "MLCT6261023002-1");
+
+        Map<String, Object> result = service.query("erp-unfulfilled-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("from DSDATA.xmdg_t"));
+        assertEquals(true, countSql.getValue().contains("inner join DSDATA.xmda_t"));
+        assertEquals(true, countSql.getValue().contains("inner join DSDATA.xmdc_t"));
+        assertEquals(true, countSql.getValue().contains("inner join DSDATA.imaal_t"));
+        assertEquals(true, countSql.getValue().contains("inner join DSDATA.pmaal_t"));
+        assertEquals(true, countSql.getValue().contains("xmda_t.xmdasite = xmdc_t.xmdcsite"));
+        assertEquals(true, countSql.getValue().contains("xmdg_t.xmdgent = :ent and xmdg_t.xmdgsite = :site"));
+        assertEquals(true, countSql.getValue().contains(
+                "xmdg_t.xmdgdocno || '-' || to_char(xmdh_t.xmdhseq) = :keyword"));
+        assertEquals(Long.valueOf(86L), countParameters.getValue().getValue("ent"));
+        assertEquals("ML", countParameters.getValue().getValue("site"));
+        assertEquals("MLCT6261023002-1", countParameters.getValue().getValue("keyword"));
+
+        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("xmdg_t.xmdgdocno, xmdh_t.xmdhseq, xmda_t.xmda048"));
+        assertEquals(true, pageSql.getValue().contains("xmdg_t.xmdg005, pmaal_t.pmaal003"));
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(6L), result.get("total"));
+    }
+
+    /**
+     * 验证出通单列表按公司、据点和指定出通状态查询,并可按出通单号模糊筛选。
+     */
+    @Test
+    void queryErpDeliveryOrderUsesCompanySiteAndDeliveryStatuses() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.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());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("ent", "明磊");
+        filters.put("site", "ML");
+        filters.put("keyword", "110001-1");
+
+        Map<String, Object> result = service.query("erp-delivery-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("xmdg_t.xmdgstus in ('Y', 'H', 'N')"));
+        assertEquals(true, countSql.getValue().contains("xmdg_t.xmdgsite = :site and xmdg_t.xmdgent = :ent"));
+        assertEquals(true, countSql.getValue().contains(
+                "and (xmdg_t.xmdgdocno || '-' || to_char(xmdh_t.xmdhseq)) like :keyword"));
+        assertEquals(true, countSql.getValue().contains("left join DSDATA.xmdc_t"));
+        assertEquals(Long.valueOf(86L), countParameters.getValue().getValue("ent"));
+        assertEquals("ML", countParameters.getValue().getValue("site"));
+        assertEquals("%110001-1%", countParameters.getValue().getValue("keyword"));
+
+        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("case when xmdc_t.xmdc019 = '9' then '样品' else '一般' end xmdc019"));
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(8L), result.get("total"));
+    }
+
+    /**
+     * 验证委外采购单开窗使用表单采购单号,并固定限定明磊公司与 ML 据点。
+     */
+    @Test
+    void queryErpOutsourcedPurchaseOrderWindowUsesPurchaseOrderNumberAndFixedPage() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(3L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("formtable_main_158_dt1_wwcgdh", "WWCG-001");
+
+        Map<String, Object> result = service.query("erp-outsourced-purchase-order-window", 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("pmdp_t.pmdpent = 86 and pmdp_t.pmdpsite = 'ML'"));
+        assertEquals(true, countSql.getValue().contains("left join DSDATA.sfaa_t"));
+        assertEquals(true, countSql.getValue().contains("sfaa_t.sfaadocno = pmdp_t.pmdp003"));
+        assertEquals(true, countSql.getValue().contains("pmdp_t.pmdpdocno = :formtable_main_158_dt1_wwcgdh"));
+        assertEquals("WWCG-001", countParameters.getValue().getValue("formtable_main_158_dt1_wwcgdh"));
+
+        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 pmdp_t.pmdpseq, pmdp_t.pmdp003, pmdp_t.pmdp001"));
+        assertEquals(true, pageSql.getValue().contains("sfaa_t.sfaaud009"));
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(3L), result.get("total"));
+    }
+
+    /**
+     * 验证委外采购对应工单备料使用采购单号与采购项次,并固定限定明磊公司与 ML 据点。
+     */
+    @Test
+    void queryErpOutsourcedBomUsesPurchaseOrderAndSequence() {
+        ObjectProvider<NamedParameterJdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.queryForObject(anyString(), any(SqlParameterSource.class), eq(Long.class)))
+                .willReturn(Long.valueOf(2L));
+        given(jdbcTemplate.queryForList(anyString(), any(SqlParameterSource.class)))
+                .willReturn(Collections.<Map<String, Object>>emptyList());
+
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(jdbcTemplateProvider);
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("formtable_main_158_dt1_wwcgdh", "WWCG-001");
+        filters.put("formtable_main_158_dt1_cgxc", "10");
+
+        Map<String, Object> result = service.query("erp-outsourced-bom", 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("pmdpent = 86 and pmdpsite = 'ML'"));
+        assertEquals(true, countSql.getValue().contains("pmdpdocno = :formtable_main_158_dt1_wwcgdh"));
+        assertEquals(true, countSql.getValue().contains("pmdpseq = :formtable_main_158_dt1_cgxc"));
+        assertEquals("WWCG-001", countParameters.getValue().getValue("formtable_main_158_dt1_wwcgdh"));
+        assertEquals("10", countParameters.getValue().getValue("formtable_main_158_dt1_cgxc"));
+
+        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 sfba006, imaal003, imaal004, sfba013"));
+        assertEquals(Long.valueOf(0L), pageParameters.getValue().getValue("offset"));
+        assertEquals(Long.valueOf(100L), pageParameters.getValue().getValue("endRow"));
+        assertEquals(1, result.get("page"));
+        assertEquals(100, result.get("pageSize"));
+        assertEquals(Long.valueOf(2L), result.get("total"));
+    }
+
+    /**
+     * 验证数据字典不接受调用方传入的分页参数。
+     */
+    @Test
+    void queryRejectsPageSizeFilter() {
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(mock(ObjectProvider.class));
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("t100id", "ML");
+        filters.put("ent", "明磊");
+        filters.put("pageSize", "100");
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> service.query("erp-supplier", filters));
+
+        assertEquals("Unsupported filter for erp-supplier: pageSize", exception.getMessage());
+    }
+
+    /**
+     * 验证已删除的封存单字典编码不会触发任意数据库查询。
+     */
+    @Test
+    void queryRejectsRemovedErpArchiveOrderDictionaryCode() {
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(mock(ObjectProvider.class));
+        String removedDictionaryCode = "erp-archive-order";
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> service.query(removedDictionaryCode, Collections.<String, String>emptyMap()));
+
+        assertEquals("dictionaryCode is not registered: " + removedDictionaryCode, exception.getMessage());
+    }
+
+    /**
+     * 验证旧的长字典编码不保留兼容路由,避免调用方继续依赖已废弃路径。
+     */
+    @Test
+    void queryRejectsLegacyOutsourcedBomDictionaryCode() {
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(mock(ObjectProvider.class));
+        String legacyDictionaryCode = "erp-outsourced-purchase-work-order-material-window";
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> service.query(legacyDictionaryCode, Collections.<String, String>emptyMap()));
+
+        assertEquals("dictionaryCode is not registered: " + legacyDictionaryCode, exception.getMessage());
+    }
+
+    /**
+     * 验证未声明的筛选条件不会进入供应商视图 SQL。
+     */
+    @Test
+    void queryErpSupplierRejectsUnsupportedFilter() {
+        DictionaryQueryServiceImpl service = new DictionaryQueryServiceImpl(mock(ObjectProvider.class));
+        Map<String, String> filters = new LinkedHashMap<String, String>();
+        filters.put("t100id", "ML");
+        filters.put("ent", "明磊");
+        filters.put("supplierCode", "SUP-001");
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> service.query("erp-supplier", filters));
+
+        assertEquals("Unsupported filter for erp-supplier: supplierCode", exception.getMessage());
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> records(Map<String, Object> result) {
+        return (List<Map<String, Object>>) result.get("records");
+    }
+
+    private Map<String, Object> pageRow(String columnName, Object value) {
+        Map<String, Object> row = new LinkedHashMap<String, Object>();
+        row.put(columnName, value);
+        row.put("DICTIONARY_PAGE_ROW_NUM", Long.valueOf(1L));
+        return row;
+    }
+}

+ 107 - 0
src/test/java/com/malk/minglei/service/impl/employee/DingTalkEmployeeRosterDuplicateNameNotifierTest.java

@@ -0,0 +1,107 @@
+package com.malk.minglei.service.impl.employee;
+
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.dingtalk.DDClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+class DingTalkEmployeeRosterDuplicateNameNotifierTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void sendsDuplicateNameNoticeToHumanResourcesUsersFromFirstYidaRecord() {
+        YDClient yidaClient = mock(YDClient.class);
+        DDClient dingTalkClient = mock(DDClient.class);
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        DDR_New yidaResponse = mock(DDR_New.class);
+        DDConf dingTalkConfiguration = new DDConf();
+        dingTalkConfiguration.setAgentId(123456L);
+
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getMasterData().setAppType("APP-TEST");
+        applications.getMasterData().setSystemToken("system-token");
+        given(yidaClient.queryData(any(), eq(YDConf.FORM_QUERY.retrieve_search_form))).willReturn(yidaResponse);
+        given(yidaResponse.getRecords()).willReturn(Collections.singletonList(mapOf(
+                "formData", mapOf("employeeField_mv94lty1_id", Arrays.asList(
+                        mapOf("userid", "hr-1"), mapOf("userId", "hr-2"))))));
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(restTemplate.exchange(
+                any(String.class),
+                eq(HttpMethod.POST),
+                any(HttpEntity.class),
+                any(ParameterizedTypeReference.class),
+                eq("access-token")))
+                .willReturn(ResponseEntity.ok(Collections.singletonMap("errcode", 0)));
+
+        DingTalkEmployeeRosterDuplicateNameNotifier notifier = new DingTalkEmployeeRosterDuplicateNameNotifier(
+                yidaClient,
+                applications,
+                dingTalkClient,
+                dingTalkConfiguration,
+                restTemplate);
+
+        notifier.notifyDuplicateUsernames(Collections.singletonMap("张三", Integer.valueOf(2)));
+
+        ArgumentCaptor<HttpEntity> requestCaptor = ArgumentCaptor.forClass(HttpEntity.class);
+        verify(restTemplate).exchange(
+                any(String.class),
+                eq(HttpMethod.POST),
+                requestCaptor.capture(),
+                any(ParameterizedTypeReference.class),
+                eq("access-token"));
+        Map<String, Object> requestBody = (Map<String, Object>) requestCaptor.getValue().getBody();
+        assertEquals("hr-1,hr-2", requestBody.get("userid_list"));
+        Map<String, Object> message = (Map<String, Object>) requestBody.get("msg");
+        Map<String, String> text = (Map<String, String>) message.get("text");
+        assertEquals("员工花名册同步发现以下在职重名人员,请核实:张三(2人)", text.get("content"));
+    }
+
+    @Test
+    void skipsYidaAndDingTalkCallsWhenNoDuplicateNameExists() {
+        YDClient yidaClient = mock(YDClient.class);
+        DDClient dingTalkClient = mock(DDClient.class);
+        RestTemplate restTemplate = mock(RestTemplate.class);
+
+        DingTalkEmployeeRosterDuplicateNameNotifier notifier = new DingTalkEmployeeRosterDuplicateNameNotifier(
+                yidaClient,
+                new YidaApplicationsProperties(),
+                dingTalkClient,
+                new DDConf(),
+                restTemplate);
+
+        notifier.notifyDuplicateUsernames(Collections.<String, Integer>emptyMap());
+
+        verify(yidaClient, never()).queryData(any(), any());
+        verify(dingTalkClient, never()).getAccessToken();
+        verifyNoInteractions(restTemplate);
+    }
+
+    private static Map<String, Object> mapOf(String key, Object value) {
+        Map<String, Object> values = new LinkedHashMap<String, Object>();
+        values.put(key, value);
+        return values;
+    }
+}

+ 148 - 0
src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepositoryTest.java

@@ -0,0 +1,148 @@
+package com.malk.minglei.service.impl.employee;
+
+import com.malk.minglei.config.EmployeeRosterProperties;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.PreparedStatementSetter;
+import org.springframework.jdbc.core.ResultSetExtractor;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.PreparedStatement;
+import java.time.LocalDate;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class EmployeeRosterRepositoryTest {
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void reserveNextEmployeeNoUsesEmployeeRosterEmployeeNoAndLocksDailyRange() throws Exception {
+        JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
+        ObjectProvider<JdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        given(jdbcTemplate.query(any(String.class), any(PreparedStatementSetter.class), any(ResultSetExtractor.class)))
+                .willReturn("2610260009");
+
+        EmployeeRosterRepository repository = new EmployeeRosterRepository(
+                jdbcTemplateProvider,
+                new EmployeeRosterProperties());
+
+        assertEquals("2610260010", repository.reserveNextEmployeeNo(LocalDate.of(2026, 10, 26)));
+
+        ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<PreparedStatementSetter> setterCaptor = ArgumentCaptor.forClass(PreparedStatementSetter.class);
+        verify(jdbcTemplate).query(sqlCaptor.capture(), setterCaptor.capture(), any(ResultSetExtractor.class));
+        assertTrue(sqlCaptor.getValue().contains("FROM employee_roster"));
+        assertTrue(sqlCaptor.getValue().contains("employee_no"));
+        assertTrue(sqlCaptor.getValue().contains("FOR UPDATE"));
+        assertFalse(sqlCaptor.getValue().contains("employee_roster_daily_sequence"));
+
+        PreparedStatement statement = mock(PreparedStatement.class);
+        setterCaptor.getValue().setValues(statement);
+        verify(statement).setString(1, "261026");
+        verify(statement).setString(2, "261026:");
+        verify(statement).setString(3, "^261026[0-9]{4}$");
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void findByEmployeeNoFiltersResignedEmployees() {
+        JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
+        ObjectProvider<JdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+
+        EmployeeRosterRepository repository = new EmployeeRosterRepository(
+                jdbcTemplateProvider,
+                new EmployeeRosterProperties());
+
+        repository.findByEmployeeNo("1001");
+
+        ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).query(sqlCaptor.capture(), any(PreparedStatementSetter.class), any(RowMapper.class));
+        assertTrue(sqlCaptor.getValue().contains("employee_no = ? AND is_resigned = 1"));
+        assertTrue(sqlCaptor.getValue().contains("hire_date"));
+        assertTrue(sqlCaptor.getValue().contains("position_name"));
+        assertTrue(sqlCaptor.getValue().contains("bank_card_no"));
+        assertTrue(sqlCaptor.getValue().contains("bank_name"));
+        assertTrue(sqlCaptor.getValue().contains("bank_branch_code"));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void findByUsernameFiltersResignedEmployees() {
+        JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
+        ObjectProvider<JdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+
+        EmployeeRosterRepository repository = new EmployeeRosterRepository(
+                jdbcTemplateProvider,
+                new EmployeeRosterProperties());
+
+        repository.findByUsername("张三");
+
+        ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).query(sqlCaptor.capture(), any(PreparedStatementSetter.class), any(RowMapper.class));
+        assertTrue(sqlCaptor.getValue().contains("SELECT employee_no FROM"));
+        assertTrue(sqlCaptor.getValue().contains("username = ? AND is_resigned = 1"));
+        assertFalse(sqlCaptor.getValue().contains("hire_date"));
+        assertFalse(sqlCaptor.getValue().contains("position_name"));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void duplicateNameQueryOnlyCountsActiveEmployees() {
+        JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
+        ObjectProvider<JdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+
+        EmployeeRosterRepository repository = new EmployeeRosterRepository(
+                jdbcTemplateProvider,
+                new EmployeeRosterProperties());
+
+        repository.findActiveDuplicateUsernameCounts();
+
+        ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).query(sqlCaptor.capture(), any(ResultSetExtractor.class));
+        assertTrue(sqlCaptor.getValue().contains("WHERE is_resigned = 1"));
+        assertTrue(sqlCaptor.getValue().contains("HAVING COUNT(*) > 1"));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void upsertBindsHireDateAndPositionName() throws Exception {
+        JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
+        ObjectProvider<JdbcTemplate> jdbcTemplateProvider = mock(ObjectProvider.class);
+        given(jdbcTemplateProvider.getIfAvailable()).willReturn(jdbcTemplate);
+        EmployeeRosterRecord record = new EmployeeRosterRecord();
+        record.setDingtalkId("user-1");
+        record.setUsername("Alice");
+        record.setHireDate("2024-01-15");
+        record.setPositionName("研发工程师");
+
+        EmployeeRosterRepository repository = new EmployeeRosterRepository(
+                jdbcTemplateProvider,
+                new EmployeeRosterProperties());
+
+        repository.upsertAll(Collections.singletonList(record));
+
+        ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<PreparedStatementSetter> setterCaptor = ArgumentCaptor.forClass(PreparedStatementSetter.class);
+        verify(jdbcTemplate).update(sqlCaptor.capture(), setterCaptor.capture());
+        assertTrue(sqlCaptor.getValue().contains("hire_date"));
+        assertTrue(sqlCaptor.getValue().contains("position_name"));
+
+        PreparedStatement statement = mock(PreparedStatement.class);
+        setterCaptor.getValue().setValues(statement);
+        verify(statement).setString(10, "2024-01-15");
+        verify(statement).setString(11, "研发工程师");
+    }
+}

+ 347 - 0
src/test/java/com/malk/minglei/service/impl/port/SupplierPriceApprovalProcessServiceImplTest.java

@@ -0,0 +1,347 @@
+package com.malk.minglei.service.impl.port;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import com.malk.minglei.service.port.MingleiPortService;
+import com.malk.minglei.service.yida.YidaFormInstanceClient;
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.dingtalk.DDR_New;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 验证包材料号清单维护审核流程发送至 T100 时的字段映射。
+ */
+class SupplierPriceApprovalProcessServiceImplTest {
+
+    private static final String PACKAGING_MATERIAL_ITEM_LIST_FORM_ID =
+            "FORM-F88BDA6B3A114E668AD5A6347AE4E01C91O9";
+    private static final String SUPPLIER_PRICE_APPROVAL_FORM_ID =
+            "FORM-468B6FB2D5BF490A8BC6DBD6FAF9BCCA28R9";
+    private static final String ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID =
+            "FORM-F906C997A4A44DC8B9180198C0B2E55F6W0S";
+    private static final String SUBSIDIARY_SUPPLIER_PRICE_APPROVAL_FORM_ID =
+            "FORM-C09818E73B1640A9AA9F6D7F55C47612EFV8";
+    private static final String SUBSIDIARY_ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID =
+            "FORM-EC46C7109182497796AA0B70F3848576UYBU";
+
+    /**
+     * 确保子表隐藏字段的包材材质编号 ID 和供应商 ID 分别写入正确的 T100 字段。
+     */
+    @Test
+    void submitsPackagingMaterialAndSupplierIdsUsingSpecifiedFieldMappings() {
+        YidaFormInstanceClient yidaFormInstanceClient = mock(YidaFormInstanceClient.class);
+        YidaProcessOpenApiClient yidaProcessOpenApiClient = mock(YidaProcessOpenApiClient.class);
+        MingleiPortService mingleiPortService = mock(MingleiPortService.class);
+        SupplierPriceApprovalProcessServiceImpl service = new SupplierPriceApprovalProcessServiceImpl(
+                yidaFormInstanceClient, yidaProcessOpenApiClient, applications(), mingleiPortService,
+                mock(EmployeeRosterService.class), new ObjectMapper());
+        given(yidaFormInstanceClient.getFormInstance(PACKAGING_MATERIAL_ITEM_LIST_FORM_ID, "process-instance-1"))
+                .willReturn(processInstance());
+        given(mingleiPortService.submitT100Document(org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
+                eq("partNumberListAudit"))).willReturn(Collections.<String, Object>singletonMap("srvcode", "100"));
+
+        service.submitByProcessInstanceId(PACKAGING_MATERIAL_ITEM_LIST_FORM_ID, "process-instance-1");
+
+        ArgumentCaptor<Map<String, Object>> parameterCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(mingleiPortService).submitT100Document(parameterCaptor.capture(), eq("partNumberListAudit"));
+        Map<?, ?> head = (Map<?, ?>) parameterCaptor.getValue().get("head");
+        List<?> details = (List<?>) head.get("detail");
+        Map<?, ?> detail = (Map<?, ?>) details.get(0);
+        assertEquals("package-material-one-id", detail.get("pmabuc001"));
+        assertEquals("supplier-id", detail.get("pmabuc002"));
+        assertEquals(new BigDecimal("24"), detail.get("pmabuc024"));
+        assertEquals("ML", detail.get("pmabucsite"));
+        assertEquals("Y", detail.get("pmabucstus"));
+    }
+
+    /**
+     * T100 成功后,四张供应商价格审批单均应按流程实例 ID 回写各自的 T100 单号字段。
+     */
+    @Test
+    void writesBackT100DocumentNumberForEachSupplierPriceApprovalForm() throws Exception {
+        ObjectMapper objectMapper = new ObjectMapper();
+        YidaFormInstanceClient yidaFormInstanceClient = mock(YidaFormInstanceClient.class);
+        YidaProcessOpenApiClient yidaProcessOpenApiClient = mock(YidaProcessOpenApiClient.class);
+        MingleiPortService mingleiPortService = mock(MingleiPortService.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        SupplierPriceApprovalProcessServiceImpl service = new SupplierPriceApprovalProcessServiceImpl(
+                yidaFormInstanceClient, yidaProcessOpenApiClient, applications(), mingleiPortService,
+                employeeRosterService, objectMapper);
+
+        String[][] formWriteBackFields = {
+                {SUPPLIER_PRICE_APPROVAL_FORM_ID, "textField_mszqctqb"},
+                {ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID, "textField_mt7yf5wu"},
+                {SUBSIDIARY_SUPPLIER_PRICE_APPROVAL_FORM_ID, "textField_mszqctqb"},
+                {SUBSIDIARY_ESTIMATED_SUPPLIER_PRICE_APPROVAL_FORM_ID, "textField_mt7yf5wu"}
+        };
+        EmployeeRosterInfo applicant = new EmployeeRosterInfo();
+        applicant.setEmployeeNo("EMP-001");
+        given(employeeRosterService.findByUsername("Applicant")).willReturn(applicant);
+        given(mingleiPortService.submitSupplierPriceApproval(org.mockito.ArgumentMatchers.any()))
+                .willReturn(t100SuccessResponse("MLWH126100100003"));
+        given(yidaProcessOpenApiClient.updateProcessInstance(org.mockito.ArgumentMatchers.<Map<String, Object>>any()))
+                .willReturn(successfulYidaResponse());
+
+        for (int index = 0; index < formWriteBackFields.length; index++) {
+            String formId = formWriteBackFields[index][0];
+            String formInstanceId = "form-instance-" + index;
+            String processInstanceId = "process-instance-" + index;
+            given(yidaFormInstanceClient.getFormInstance(formId, formInstanceId))
+                    .willReturn(supplierPriceProcessInstance(formId));
+            service.submitByInstanceIds(formId, formInstanceId, processInstanceId);
+        }
+
+        ArgumentCaptor<Map<String, Object>> updateBodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(yidaProcessOpenApiClient, times(4)).updateProcessInstance(updateBodyCaptor.capture());
+        List<Map<String, Object>> updateBodies = updateBodyCaptor.getAllValues();
+        for (int index = 0; index < formWriteBackFields.length; index++) {
+            Map<String, Object> updateBody = updateBodies.get(index);
+            assertEquals("form-instance-" + index, updateBody.get("formInstanceId"));
+            assertEquals("process-instance-" + index, updateBody.get("processInstanceId"));
+            assertEquals("APP_RJJL69QUIZVQSV7YC8TL", updateBody.get("appType"));
+            Map<?, ?> updateFormData = objectMapper.readValue(
+                    String.valueOf(updateBody.get("updateFormDataJson")), Map.class);
+            assertEquals("MLWH126100100003", updateFormData.get(formWriteBackFields[index][1]));
+            assertEquals(1, updateFormData.size());
+        }
+    }
+
+    @Test
+    void readsByFormInstanceIdWhenProcessInstanceIdIsAlsoProvided() throws Exception {
+        ObjectMapper objectMapper = new ObjectMapper();
+        YidaFormInstanceClient yidaFormInstanceClient = mock(YidaFormInstanceClient.class);
+        YidaProcessOpenApiClient yidaProcessOpenApiClient = mock(YidaProcessOpenApiClient.class);
+        MingleiPortService mingleiPortService = mock(MingleiPortService.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        SupplierPriceApprovalProcessServiceImpl service = new SupplierPriceApprovalProcessServiceImpl(
+                yidaFormInstanceClient, yidaProcessOpenApiClient, applications(), mingleiPortService,
+                employeeRosterService, objectMapper);
+
+        EmployeeRosterInfo applicant = new EmployeeRosterInfo();
+        applicant.setEmployeeNo("EMP-001");
+        given(employeeRosterService.findByUsername("Applicant")).willReturn(applicant);
+        given(yidaFormInstanceClient.getFormInstance(SUPPLIER_PRICE_APPROVAL_FORM_ID, "form-instance-001"))
+                .willReturn(supplierPriceProcessInstance(SUPPLIER_PRICE_APPROVAL_FORM_ID));
+        given(mingleiPortService.submitSupplierPriceApproval(org.mockito.ArgumentMatchers.any()))
+                .willReturn(t100SuccessResponse("MLWH126100100003"));
+        given(yidaProcessOpenApiClient.updateProcessInstance(org.mockito.ArgumentMatchers.<Map<String, Object>>any()))
+                .willReturn(successfulYidaResponse());
+
+        service.submitByInstanceIds(SUPPLIER_PRICE_APPROVAL_FORM_ID,
+                "form-instance-001", "process-instance-001");
+
+        verify(yidaFormInstanceClient).getFormInstance(SUPPLIER_PRICE_APPROVAL_FORM_ID, "form-instance-001");
+        ArgumentCaptor<Map<String, Object>> updateBodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(yidaProcessOpenApiClient).updateProcessInstance(updateBodyCaptor.capture());
+        assertEquals("form-instance-001", updateBodyCaptor.getValue().get("formInstanceId"));
+        assertEquals("process-instance-001", updateBodyCaptor.getValue().get("processInstanceId"));
+    }
+
+    @Test
+    void rejectsPriceApprovalWithoutProcessInstanceIdBeforeT100Push() {
+        YidaFormInstanceClient yidaFormInstanceClient = mock(YidaFormInstanceClient.class);
+        YidaProcessOpenApiClient yidaProcessOpenApiClient = mock(YidaProcessOpenApiClient.class);
+        MingleiPortService mingleiPortService = mock(MingleiPortService.class);
+        SupplierPriceApprovalProcessServiceImpl service = new SupplierPriceApprovalProcessServiceImpl(
+                yidaFormInstanceClient, yidaProcessOpenApiClient, applications(), mingleiPortService,
+                mock(EmployeeRosterService.class), new ObjectMapper());
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> service.submitByInstanceIds(SUPPLIER_PRICE_APPROVAL_FORM_ID, "form-instance-001", null));
+
+        assertEquals("processInstanceId must not be blank for supplier price approval forms", exception.getMessage());
+        verifyNoInteractions(yidaFormInstanceClient, mingleiPortService, yidaProcessOpenApiClient);
+    }
+
+    @Test
+    void preservesNonJsonTextThatStartsWithBrace() {
+        String formId = "FORM-0A05D30C6E0745EC9F4B74D0E038EE73O7B6";
+        YidaFormInstanceClient yidaFormInstanceClient = mock(YidaFormInstanceClient.class);
+        YidaProcessOpenApiClient yidaProcessOpenApiClient = mock(YidaProcessOpenApiClient.class);
+        MingleiPortService mingleiPortService = mock(MingleiPortService.class);
+        SupplierPriceApprovalProcessServiceImpl service = new SupplierPriceApprovalProcessServiceImpl(
+                yidaFormInstanceClient, yidaProcessOpenApiClient, applications(), mingleiPortService,
+                mock(EmployeeRosterService.class), new ObjectMapper());
+
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        formData.put("selectField_mt82p09c", "正常供应商");
+        formData.put("textField_mvgepmw0", "{supplier-code");
+        formData.put("textField_mvewj97z", "明磊");
+        formData.put("textField_mvewj980", "ML");
+        Map<String, Object> instance = new LinkedHashMap<String, Object>();
+        instance.put("formUuid", formId);
+        instance.put("formData", formData);
+        given(yidaFormInstanceClient.getFormInstance(formId, "form-instance-001")).willReturn(instance);
+        given(mingleiPortService.submitT100Document(org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
+                eq("suspensionExit"))).willReturn(Collections.<String, Object>singletonMap("srvcode", "000"));
+
+        service.submitByInstanceIds(formId, "form-instance-001", null);
+
+        ArgumentCaptor<Map<String, Object>> parameterCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(mingleiPortService).submitT100Document(parameterCaptor.capture(), eq("suspensionExit"));
+        Map<?, ?> head = (Map<?, ?>) parameterCaptor.getValue().get("head");
+        assertEquals("{supplier-code", head.get("pmaa001"));
+        verifyNoInteractions(yidaProcessOpenApiClient);
+    }
+
+    /**
+     * T100 实际直返 payload 时,执行状态码为 0 也必须触发宜搭单号回写。
+     */
+    @Test
+    void writesBackT100DocumentNumberForRawT100SuccessResponse() throws Exception {
+        ObjectMapper objectMapper = new ObjectMapper();
+        YidaFormInstanceClient yidaFormInstanceClient = mock(YidaFormInstanceClient.class);
+        YidaProcessOpenApiClient yidaProcessOpenApiClient = mock(YidaProcessOpenApiClient.class);
+        MingleiPortService mingleiPortService = mock(MingleiPortService.class);
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        SupplierPriceApprovalProcessServiceImpl service = new SupplierPriceApprovalProcessServiceImpl(
+                yidaFormInstanceClient, yidaProcessOpenApiClient, applications(), mingleiPortService,
+                employeeRosterService, objectMapper);
+        EmployeeRosterInfo applicant = new EmployeeRosterInfo();
+        applicant.setEmployeeNo("EMP-001");
+        given(employeeRosterService.findByUsername("Applicant")).willReturn(applicant);
+        given(yidaFormInstanceClient.getFormInstance(SUPPLIER_PRICE_APPROVAL_FORM_ID, "process-raw"))
+                .willReturn(supplierPriceProcessInstance(SUPPLIER_PRICE_APPROVAL_FORM_ID));
+        given(mingleiPortService.submitSupplierPriceApproval(org.mockito.ArgumentMatchers.any()))
+                .willReturn(rawT100SuccessResponse("MLWH126100100003"));
+        given(yidaProcessOpenApiClient.updateProcessInstance(org.mockito.ArgumentMatchers.<Map<String, Object>>any()))
+                .willReturn(successfulYidaResponse());
+
+        service.submitByInstanceIds(SUPPLIER_PRICE_APPROVAL_FORM_ID, "process-raw", "process-raw-001");
+
+        ArgumentCaptor<Map<String, Object>> updateBodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(yidaProcessOpenApiClient).updateProcessInstance(updateBodyCaptor.capture());
+        Map<?, ?> updateFormData = objectMapper.readValue(
+                String.valueOf(updateBodyCaptor.getValue().get("updateFormDataJson")), Map.class);
+        assertEquals("MLWH126100100003", updateFormData.get("textField_mszqctqb"));
+        assertEquals("process-raw-001", updateBodyCaptor.getValue().get("processInstanceId"));
+    }
+
+    /**
+     * 构造仅包含当前映射所需字段的宜搭流程实例,避免耦合其他表单字段。
+     */
+    private Map<String, Object> processInstance() {
+        Map<String, Object> row = new LinkedHashMap<String, Object>();
+        row.put("textField_mvix39fn", "package-material-one-id");
+        row.put("textField_mvix39fm", "supplier-id");
+        row.put("numberField_mvjb17mf", 24);
+
+        Map<String, Object> enterpriseSite = component("selectField_msy2rh6q", "明磊");
+        Map<String, Object> detailTable = component("tableField_msh186sv", Collections.singletonList(row));
+        Map<String, Object> processInstance = new LinkedHashMap<String, Object>();
+        processInstance.put("formUuid", PACKAGING_MATERIAL_ITEM_LIST_FORM_ID);
+        processInstance.put("formComponentValues", Arrays.asList(enterpriseSite, detailTable));
+        return processInstance;
+    }
+
+    private Map<String, Object> supplierPriceProcessInstance(String formId) {
+        Map<String, Object> pricingRow = new LinkedHashMap<String, Object>();
+        pricingRow.put("selectField_mvhrhghk", "ITEM-001");
+        pricingRow.put("selectField_mvanfd8d", "ITEM-001");
+        pricingRow.put("selectField_mvejv4mz", "ITEM-001");
+        pricingRow.put("selectField_mvf0cizk", "ITEM-001");
+        pricingRow.put("numberField_mszqctqh", 10);
+        pricingRow.put("numberField_mt6viiqh", 10);
+        pricingRow.put("textField_mszqctql", "remark");
+        pricingRow.put("textField_mt6ur4w8", "remark");
+
+        List<Map<String, Object>> components = Arrays.asList(
+                component("employeeField_msh186rr", "Applicant"),
+                component("selectField_mszqctpc", "D001"),
+                component("departmentSelectField_msh186ry", "D001"),
+                component("selectField_mt7yf5x2", "D001"),
+                component("textField_mvhhjsy8", "DOC-001"),
+                component("textField_mti3631t", "DOC-001"),
+                component("textField_mvhhkiyu", "DOC-001"),
+                component("textField_mvhhbywg", "DOC-001"),
+                component("dateField_mszqctpf", "2026-10-23"),
+                component("dateField_mt7yf5w6", "2026-10-23"),
+                component("textField_mvius864", "SUP-001"),
+                component("dateField_mszqctpx", "2026-10-23"),
+                component("dateField_mszqctpy", "2026-10-24"),
+                component("dateField_mt7yf5wm", "2026-10-23"),
+                component("dateField_mt7yf5wn", "2026-10-24"),
+                component("selectField_mszqctq2", "否"),
+                component("selectField_mt7yf5wo", "否"),
+                component("serialNumberField_msmwc0yx", "SN-001"),
+                component("tableField_mszqctqd", Collections.singletonList(pricingRow)),
+                component("tableField_msh186sv", Collections.singletonList(pricingRow)));
+        Map<String, Object> processInstance = new LinkedHashMap<String, Object>();
+        processInstance.put("formUuid", formId);
+        processInstance.put("formComponentValues", components);
+        return processInstance;
+    }
+
+    private Map<String, Object> t100SuccessResponse(String documentNumber) {
+        Map<String, Object> parameter = new LinkedHashMap<String, Object>();
+        parameter.put("pmdidocno", documentNumber);
+        Map<String, Object> standardData = new LinkedHashMap<String, Object>();
+        standardData.put("parameter", parameter);
+        Map<String, Object> payload = new LinkedHashMap<String, Object>();
+        payload.put("std_data", standardData);
+        Map<String, Object> content = new LinkedHashMap<String, Object>();
+        content.put("payload", payload);
+        Map<String, Object> response = new LinkedHashMap<String, Object>();
+        response.put("success", true);
+        response.put("content", content);
+        return response;
+    }
+
+    private Map<String, Object> rawT100SuccessResponse(String documentNumber) {
+        Map<String, Object> execution = new LinkedHashMap<String, Object>();
+        execution.put("code", "0");
+        Map<String, Object> parameter = new LinkedHashMap<String, Object>();
+        parameter.put("pmdidocno", documentNumber);
+        Map<String, Object> standardData = new LinkedHashMap<String, Object>();
+        standardData.put("execution", execution);
+        standardData.put("parameter", parameter);
+        Map<String, Object> payload = new LinkedHashMap<String, Object>();
+        payload.put("std_data", standardData);
+        Map<String, Object> response = new LinkedHashMap<String, Object>();
+        response.put("payload", payload);
+        return response;
+    }
+
+    private DDR_New successfulYidaResponse() {
+        DDR_New response = new DDR_New();
+        response.setSuccess(true);
+        return response;
+    }
+
+    private YidaApplicationsProperties applications() {
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
+        applications.getCoordinationOffice().setSystemToken("system-token");
+        applications.getCoordinationOffice().setProcessInstanceQueryUserId("41603502201288023");
+        return applications;
+    }
+
+    /**
+     * 构造宜搭表单控件值。
+     */
+    private Map<String, Object> component(String fieldId, Object value) {
+        Map<String, Object> component = new LinkedHashMap<String, Object>();
+        component.put("fieldId", fieldId);
+        component.put("value", value);
+        return component;
+    }
+}

+ 127 - 0
src/test/java/com/malk/minglei/service/impl/port/TravelApplicationPushServiceImplTest.java

@@ -0,0 +1,127 @@
+package com.malk.minglei.service.impl.port;
+
+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.yida.YidaProcessOpenApiClient;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR_New;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 出差申请 OA 推送服务测试。
+ */
+class TravelApplicationPushServiceImplTest {
+
+    /**
+     * 验证 OA 主表和三张子表会转换为对应的宜搭字段,并使用流程所属的协同办公应用凭据发起流程。
+     */
+    @Test
+    void mapsOaTravelApplicationToYidaProcess() {
+        CapturingYidaClient client = new CapturingYidaClient();
+        TravelApplicationPushServiceImpl service = new TravelApplicationPushServiceImpl(client, dingTalkConfiguration(), yidaApplications());
+
+        Map<String, Object> result = service.push(createRequest());
+
+        assertEquals("oa-process-001", result.get("oaProcessInstanceId"));
+        assertEquals("yida-process-001", result.get("yidaProcessInstanceId"));
+        assertEquals("APP_RJJL69QUIZVQSV7YC8TL", client.body.get("appType"));
+        assertEquals("user-001", client.body.get("userId"));
+        assertEquals("TPROC--CNG66SB108EACFIFLA9FL5E4D5R12OYJNROVM0", client.body.get("processCode"));
+
+        JSONObject formData = JSON.parseObject(String.valueOf(client.body.get("formDataJson")));
+        assertEquals("北京客户拜访", formData.getString("textField_msh27unu"));
+        assertEquals("user-001", formData.getJSONArray("employeeField_msh186rr").getString(0));
+        assertEquals("123", formData.getJSONArray("departmentSelectField_msh186ry").getString(0));
+        assertEquals("北京", formData.getJSONArray("tableField_mvorr6o1").getJSONObject(0)
+                .getString("selectField_mvorr6nv"));
+        assertEquals(new BigDecimal("5600"), formData.getJSONArray("tableField_mvorr6oe").getJSONObject(0)
+                .getBigDecimal("numberField_mvorr6oj"));
+        assertEquals("北京", formData.getJSONArray("tableField_mvorr6oo").getJSONObject(0)
+                .getString("selectField_mvorr6op"));
+    }
+
+    private TravelApplicationPushRequest createRequest() {
+        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;
+    }
+
+    private DDConf dingTalkConfiguration() {
+        DDConf configuration = new DDConf();
+        configuration.setAppKey("test-app-key");
+        configuration.setAppSecret("test-app-secret");
+        return configuration;
+    }
+
+    private YidaApplicationsProperties yidaApplications() {
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getMasterData().setAppType("APP_MASTER_DATA");
+        applications.getMasterData().setSystemToken("master-data-token");
+        applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
+        applications.getCoordinationOffice().setSystemToken("test-system-token");
+        return applications;
+    }
+
+    private <T> List<T> singleton(T value) {
+        List<T> values = new ArrayList<T>();
+        values.add(value);
+        return values;
+    }
+
+    private static class CapturingYidaClient implements YidaProcessOpenApiClient {
+        private Map<String, Object> body;
+
+        @Override
+        public DDR_New startProcess(Map<String, Object> requestBody) {
+            body = new LinkedHashMap<String, Object>(requestBody);
+            DDR_New response = new DDR_New();
+            response.setSuccess(true);
+            response.setResult("yida-process-001");
+            return response;
+        }
+
+        @Override
+        public DDR_New updateProcessInstance(Map<String, Object> requestBody) {
+            throw new UnsupportedOperationException("Not used by this test");
+        }
+    }
+}

+ 273 - 0
src/test/java/com/malk/minglei/service/impl/port/TravelStandardPortServiceImplTest.java

@@ -0,0 +1,273 @@
+package com.malk.minglei.service.impl.port;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 差旅标准按员工职等查询的测试。
+ */
+class TravelStandardPortServiceImplTest {
+
+    /**
+     * 验证员工职等会作为宜搭数值条件查询,且兼容 SDK 将列表放入 data 的返回结构。
+     */
+    @Test
+    void queriesYidaStandardByJobGradeAndUsesSecondClassAmountForHainan() {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setJobGrade("8");
+        when(employeeRosterService.findByUserId("user-001")).thenReturn(employee);
+
+        YDClient yidaClient = mock(YDClient.class);
+        when(yidaClient.queryData(org.mockito.ArgumentMatchers.any(YDParam.class),
+                org.mockito.ArgumentMatchers.eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(successfulResponse("8", "600", "400", "300"));
+
+        TravelStandardPortServiceImpl service = service(employeeRosterService, yidaClient);
+        Map<String, Object> result = service.queryTravelStandards("user-001", "海南省海口市");
+
+        ArgumentCaptor<YDParam> parameterCaptor = ArgumentCaptor.forClass(YDParam.class);
+        org.mockito.Mockito.verify(yidaClient).queryData(parameterCaptor.capture(),
+                org.mockito.ArgumentMatchers.eq(YDConf.FORM_QUERY.retrieve_search_form));
+        assertEquals("APP_KI0AOTH7OLFS2UAUHZXI", parameterCaptor.getValue().getAppType());
+        assertEquals("FORM-47ED3C35E3C34A9CA275039F93FA5ED1VR2J", parameterCaptor.getValue().getFormUuid());
+        assertEquals("[{\"key\":\"numberField_mvevi2ck\",\"value\":[8,8],\"type\":\"DOUBLE\",\"operator\":\"between\",\"componentName\":\"NumberField\"}]",
+                parameterCaptor.getValue().getSearchFieldJson());
+        assertEquals("二类城市", result.get("cityClass"));
+        assertEquals(new BigDecimal("400"), result.get("amount"));
+        assertEquals("CNY", result.get("currency"));
+    }
+
+    /**
+     * 验证指定一类国家的城市读取宜搭主表中的一类国家人民币住宿标准。
+     */
+    @Test
+    void returnsFirstCountryAccommodationStandardForJapanCity() {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setJobGrade("8");
+        when(employeeRosterService.findByUserId("user-jp")).thenReturn(employee);
+
+        YDClient yidaClient = mock(YDClient.class);
+        when(yidaClient.queryData(org.mockito.ArgumentMatchers.any(YDParam.class),
+                org.mockito.ArgumentMatchers.eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(successfulInternationalAccommodationResponse("8", "1800", "900"));
+
+        TravelStandardPortServiceImpl service = service(employeeRosterService, yidaClient);
+        Map<String, Object> result = service.queryTravelStandards("user-jp", "东京");
+
+        assertEquals("日本", result.get("country"));
+        assertEquals("一类国家", result.get("cityClass"));
+        assertEquals(new BigDecimal("1800"), result.get("amount"));
+        assertEquals("CNY", result.get("currency"));
+    }
+
+    /**
+     * 验证非指定一类国家的城市,以及城市值中附带的国家名称,均读取其他国家人民币住宿标准。
+     */
+    @Test
+    void returnsOtherCountryAccommodationStandardForRecognizedCountryName() {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setJobGrade("8");
+        when(employeeRosterService.findByUserId("user-is")).thenReturn(employee);
+
+        YDClient yidaClient = mock(YDClient.class);
+        when(yidaClient.queryData(org.mockito.ArgumentMatchers.any(YDParam.class),
+                org.mockito.ArgumentMatchers.eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(successfulInternationalAccommodationResponse("8", "1800", "900"));
+
+        TravelStandardPortServiceImpl service = service(employeeRosterService, yidaClient);
+        Map<String, Object> result = service.queryTravelStandards("user-is", "雷克雅未克(冰岛)");
+
+        assertEquals("冰岛", result.get("country"));
+        assertEquals("其他国家", result.get("cityClass"));
+        assertEquals(new BigDecimal("900"), result.get("amount"));
+    }
+
+    /**
+     * 验证员工缺少职等或宜搭没有对应标准时,不会回退到后端固定金额。
+     */
+    @Test
+    void rejectsMissingEmployeeGradeOrYidaStandard() {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        YDClient yidaClient = mock(YDClient.class);
+        TravelStandardPortServiceImpl service = service(employeeRosterService, yidaClient);
+
+        assertThrows(IllegalArgumentException.class, () -> service.queryTravelStandards("user-404", "深圳"));
+
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setJobGrade("9");
+        when(employeeRosterService.findByUserId("user-009")).thenReturn(employee);
+        when(yidaClient.queryData(org.mockito.ArgumentMatchers.any(YDParam.class),
+                org.mockito.ArgumentMatchers.eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(successfulResponse("8", "600", "400", "300"));
+
+        assertThrows(IllegalArgumentException.class, () -> service.queryTravelStandards("user-009", "深圳"));
+    }
+
+    /**
+     * 验证机票金额从同一职等对应的宜搭主表字段读取,城市仅作为行程上下文返回。
+     */
+    @Test
+    void returnsFlightStandardFromMainFormField() {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setJobGrade("8");
+        employee.setJobGradeId("GRADE-8");
+        when(employeeRosterService.findByUserId("user-008")).thenReturn(employee);
+
+        YDClient yidaClient = mock(YDClient.class);
+        when(yidaClient.queryData(org.mockito.ArgumentMatchers.any(YDParam.class),
+                org.mockito.ArgumentMatchers.eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(successfulFlightResponse("8", "4000", true));
+
+        TravelStandardPortServiceImpl service = service(employeeRosterService, yidaClient);
+        Map<String, Object> result = service.queryFlightStandard("user-008", "东南亚", "往返", 2,
+                new BigDecimal("8001"));
+
+        assertEquals("FLIGHT", result.get("expenseType"));
+        assertEquals("8", result.get("jobGrade"));
+        assertEquals("GRADE-8", result.get("jobGradeId"));
+        assertEquals("东南亚", result.get("travelRegion"));
+        assertEquals("往返", result.get("ticketType"));
+        assertEquals("SOUTHEAST_ASIA_ROUND_TRIP", result.get("flightOption"));
+        assertEquals("东南亚机票-往返", result.get("flightOptionName"));
+        assertEquals(new BigDecimal("4000"), result.get("limitAmount"));
+        assertEquals(Integer.valueOf(2), result.get("tripCount"));
+        assertEquals(new BigDecimal("8001"), result.get("budgetAmount"));
+        assertEquals("是", result.get("isOverStandard"));
+        assertEquals("CNY", result.get("currency"));
+        assertEquals("PER_TRIP", result.get("unit"));
+    }
+
+    /**
+     * 验证不支持的机票选项会在查询宜搭前被拒绝。
+     */
+    @Test
+    void returnsEmptyStandardForUnsupportedFlightOption() {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        TravelStandardPortServiceImpl service = service(employeeRosterService, mock(YDClient.class));
+
+        Map<String, Object> result = service.queryFlightStandard("user-001", "国际", "往返");
+
+        assertEquals("国际", result.get("travelRegion"));
+        assertEquals("往返", result.get("ticketType"));
+        assertNull(result.get("jobGrade"));
+        assertNull(result.get("limitAmount"));
+        assertNull(result.get("currency"));
+    }
+
+    /**
+     * 验证预算金额与标准金额乘以次数相等时不属于超标。
+     */
+    @Test
+    void returnsNoWhenBudgetEqualsTotalFlightLimit() {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setJobGrade("8");
+        when(employeeRosterService.findByUserId("user-008")).thenReturn(employee);
+
+        YDClient yidaClient = mock(YDClient.class);
+        when(yidaClient.queryData(org.mockito.ArgumentMatchers.any(YDParam.class),
+                org.mockito.ArgumentMatchers.eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(successfulFlightResponse("8", "4000", true));
+
+        TravelStandardPortServiceImpl service = service(employeeRosterService, yidaClient);
+        Map<String, Object> result = service.queryFlightStandard("user-008", "东南亚", "往返", 2,
+                new BigDecimal("8000"));
+
+        assertEquals("否", result.get("isOverStandard"));
+    }
+
+    /**
+     * 验证宜搭主表未维护所选机票金额时不会返回错误的默认金额。
+     */
+    @Test
+    void rejectsMissingFlightAmount() {
+        EmployeeRosterService employeeRosterService = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo employee = new EmployeeRosterInfo();
+        employee.setJobGrade("8");
+        when(employeeRosterService.findByUserId("user-008")).thenReturn(employee);
+
+        YDClient yidaClient = mock(YDClient.class);
+        when(yidaClient.queryData(org.mockito.ArgumentMatchers.any(YDParam.class),
+                org.mockito.ArgumentMatchers.eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(successfulFlightResponse("8", null, false));
+
+        TravelStandardPortServiceImpl service = service(employeeRosterService, yidaClient);
+
+        assertThrows(IllegalArgumentException.class, () -> service.queryFlightStandard("user-008", "东南亚", "往返"));
+    }
+
+    private TravelStandardPortServiceImpl service(EmployeeRosterService employeeRosterService, YDClient yidaClient) {
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getMasterData().setAppType("APP_KI0AOTH7OLFS2UAUHZXI");
+        applications.getMasterData().setSystemToken("test-system-token");
+        return new TravelStandardPortServiceImpl(employeeRosterService, yidaClient, applications, new ObjectMapper());
+    }
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private DDR_New successfulResponse(String jobGrade, String firstCityAmount, String secondCityAmount, String thirdCityAmount) {
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        formData.put("numberField_mvevi2ck", new BigDecimal(jobGrade));
+        formData.put("numberField_mvevi2ct", new BigDecimal(firstCityAmount));
+        formData.put("numberField_mvevi2cu", new BigDecimal(secondCityAmount));
+        formData.put("numberField_mvevi2cv", new BigDecimal(thirdCityAmount));
+        Map record = new LinkedHashMap();
+        record.put("formData", formData);
+        DDR_New response = new DDR_New();
+        response.setSuccess(true);
+        response.setData(Arrays.asList(record));
+        return response;
+    }
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private DDR_New successfulFlightResponse(String jobGrade, String flightAmount, boolean configured) {
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        formData.put("numberField_mvevi2ck", new BigDecimal(jobGrade));
+        if (configured) {
+            formData.put("numberField_mvevi2de", new BigDecimal(flightAmount));
+        }
+        Map record = new LinkedHashMap();
+        record.put("formData", formData);
+        DDR_New response = new DDR_New();
+        response.setSuccess(true);
+        response.setData(Arrays.asList(record));
+        return response;
+    }
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private DDR_New successfulInternationalAccommodationResponse(String jobGrade, String firstCountryAmount,
+                                                                  String otherCountryAmount) {
+        Map<String, Object> formData = new LinkedHashMap<String, Object>();
+        formData.put("numberField_mvevi2ck", new BigDecimal(jobGrade));
+        formData.put("numberField_mvevi2d3", new BigDecimal(firstCountryAmount));
+        formData.put("numberField_mvevi2d4", new BigDecimal(otherCountryAmount));
+        Map record = new LinkedHashMap();
+        record.put("formData", formData);
+        DDR_New response = new DDR_New();
+        response.setSuccess(true);
+        response.setData(Arrays.asList(record));
+        return response;
+    }
+}

+ 117 - 0
src/test/java/com/malk/minglei/service/impl/yida/DingTalkYidaProcessOpenApiClientTest.java

@@ -0,0 +1,117 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.hamcrest.Matchers.containsString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withBadRequest;
+
+/**
+ * 验证流程实例回写遵循钉钉开放平台 UpdateFormData v2 协议。
+ */
+class DingTalkYidaProcessOpenApiClientTest {
+
+    @Test
+    void updatesFormInstanceUsingDingTalkOpenApi() {
+        Map<String, Object> body = new LinkedHashMap<String, Object>();
+        body.put("appType", "APP-001");
+        body.put("systemToken", "system-token");
+        body.put("formUuid", "FORM-001");
+        body.put("formInstanceId", "form-instance-001");
+        body.put("processInstanceId", "process-instance-001");
+        body.put("updateFormDataJson", "{\"textField_a\":\"document-001\"}");
+
+        DDClient dingTalkClient = mock(DDClient.class);
+        when(dingTalkClient.getAccessToken()).thenReturn("application-access-token");
+        RestTemplate restTemplate = new RestTemplate();
+        MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+        server.expect(requestTo("https://api.dingtalk.com/v2.0/yida/forms/instances"))
+                .andExpect(method(HttpMethod.PUT))
+                .andExpect(header("x-acs-dingtalk-access-token", "application-access-token"))
+                .andExpect(content().string(containsString("\"formUuid\":\"FORM-001\"")))
+                .andExpect(header("Content-Type", MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"))
+                .andExpect(content().string(containsString("\"formInstanceId\":\"form-instance-001\"")))
+                .andExpect(content().string(containsString("\"language\":\"zh_CN\"")))
+                .andExpect(content().string(containsString("\"useLatestVersion\":false")))
+                .andExpect(content().string(containsString("\"useAlias\":false")))
+                .andExpect(content().string(containsString("\"updateFormDataJson\":\"{\\\"textField_a\\\":\\\"document-001\\\"}\"")))
+                .andRespond(withSuccess("{\"success\":true,\"result\":true}", MediaType.APPLICATION_JSON));
+
+        DDR_New response = new DingTalkYidaProcessOpenApiClient(dingTalkClient, restTemplate)
+                .updateProcessInstance(body);
+
+        assertEquals(true, response.isSuccess());
+        server.verify();
+    }
+
+    @Test
+    void preservesYidaUpdateErrorBodyForDiagnostics() {
+        Map<String, Object> body = new LinkedHashMap<String, Object>();
+        body.put("appType", "APP-001");
+        body.put("systemToken", "system-token");
+        body.put("formUuid", "FORM-001");
+        body.put("formInstanceId", "form-instance-001");
+        body.put("processInstanceId", "process-instance-001");
+        body.put("updateFormDataJson", "{\"textField_a\":\"document-001\"}");
+
+        DDClient dingTalkClient = mock(DDClient.class);
+        when(dingTalkClient.getAccessToken()).thenReturn("application-access-token");
+        RestTemplate restTemplate = new RestTemplate();
+        MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+        server.expect(requestTo("https://api.dingtalk.com/v2.0/yida/forms/instances"))
+                .andExpect(method(HttpMethod.PUT))
+                .andRespond(withBadRequest().contentType(MediaType.APPLICATION_JSON)
+                        .body("{\"code\":\"InvalidParameter\",\"message\":\"formUuid is required\"}"));
+
+        DDR_New response = new DingTalkYidaProcessOpenApiClient(dingTalkClient, restTemplate)
+                .updateProcessInstance(body);
+
+        assertFalse(response.isSuccess());
+        assertEquals("400", response.getCode());
+        assertTrue(response.getMessage().contains("formUuid is required"));
+        server.verify();
+    }
+
+    @Test
+    void treatsAnEmptySuccessfulResponseAsSuccessfulUpdate() {
+        Map<String, Object> body = new LinkedHashMap<String, Object>();
+        body.put("appType", "APP-001");
+        body.put("systemToken", "system-token");
+        body.put("formUuid", "FORM-001");
+        body.put("formInstanceId", "form-instance-001");
+        body.put("updateFormDataJson", "{\"textField_a\":\"document-001\"}");
+
+        DDClient dingTalkClient = mock(DDClient.class);
+        when(dingTalkClient.getAccessToken()).thenReturn("application-access-token");
+        RestTemplate restTemplate = new RestTemplate();
+        MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+        server.expect(requestTo("https://api.dingtalk.com/v2.0/yida/forms/instances"))
+                .andExpect(method(HttpMethod.PUT))
+                .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON));
+
+        DDR_New response = new DingTalkYidaProcessOpenApiClient(dingTalkClient, restTemplate)
+                .updateProcessInstance(body);
+
+        assertTrue(response.isSuccess());
+        server.verify();
+    }
+
+}

+ 71 - 0
src/test/java/com/malk/minglei/service/impl/yida/YidaFormInstanceClientImplTest.java

@@ -0,0 +1,71 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.service.dingtalk.DDClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withUnauthorizedRequest;
+
+class YidaFormInstanceClientImplTest {
+
+    @Test
+    void queriesYidaFormDetailByFormInstanceId() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        when(dingTalkClient.getAccessToken()).thenReturn("application-access-token");
+        RestTemplate restTemplate = new RestTemplate();
+        MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+        server.expect(requestTo("https://api.dingtalk.com/v1.0/yida/forms/instances/form-instance-001?"
+                        + "appType=APP_RJJL69QUIZVQSV7YC8TL&systemToken=system-token&userId=yida_pub_account"))
+                .andExpect(method(HttpMethod.GET))
+                .andExpect(header("x-acs-dingtalk-access-token", "application-access-token"))
+                .andRespond(withSuccess("{\"result\":{\"formUuid\":\"FORM-001\",\"formInstId\":\"form-instance-001\","
+                        + "\"formData\":{\"textField_a\":\"value-a\"}}}", MediaType.APPLICATION_JSON));
+
+        YidaFormInstanceClientImpl client = new YidaFormInstanceClientImpl(dingTalkClient, restTemplate, applications());
+        Map<String, Object> result = client.getFormInstance("FORM-001", "form-instance-001");
+
+        Map<?, ?> detail = (Map<?, ?>) result.get("result");
+        assertEquals("value-a", ((Map<?, ?>) detail.get("formData")).get("textField_a"));
+        server.verify();
+    }
+
+    @Test
+    void preservesYidaErrorResponseForTheControllerAndLogsItWithTheRequestContext() {
+        DDClient dingTalkClient = mock(DDClient.class);
+        when(dingTalkClient.getAccessToken()).thenReturn("application-access-token");
+        RestTemplate restTemplate = new RestTemplate();
+        MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+        server.expect(requestTo("https://api.dingtalk.com/v1.0/yida/forms/instances/form-instance-001?"
+                        + "appType=APP_RJJL69QUIZVQSV7YC8TL&systemToken=system-token&userId=yida_pub_account"))
+                .andExpect(method(HttpMethod.GET))
+                .andRespond(withUnauthorizedRequest().contentType(MediaType.APPLICATION_JSON)
+                        .body("{\"code\":\"InvalidAccessToken\",\"message\":\"access token is invalid\"}"));
+
+        YidaFormInstanceClientImpl client = new YidaFormInstanceClientImpl(dingTalkClient, restTemplate, applications());
+
+        assertThrows(org.springframework.web.client.HttpClientErrorException.Unauthorized.class,
+                () -> client.getFormInstance("FORM-001", "form-instance-001"));
+        server.verify();
+    }
+
+    private YidaApplicationsProperties applications() {
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
+        applications.getCoordinationOffice().setSystemToken("system-token");
+        return applications;
+    }
+}

+ 54 - 0
src/test/java/com/malk/minglei/service/impl/yida/YidaProcessInstanceClientImplTest.java

@@ -0,0 +1,54 @@
+package com.malk.minglei.service.impl.yida;
+
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.service.approval.DingTalkApprovalClient;
+import com.malk.service.dingtalk.DDClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 验证宜搭流程详情客户端通过钉钉开放平台查询审批实例。
+ */
+class YidaProcessInstanceClientImplTest {
+
+    private YidaProcessInstanceClientImpl client;
+    private DDClient dingTalkClient;
+    private DingTalkApprovalClient dingTalkApprovalClient;
+    private YidaApplicationsProperties applications;
+
+    @BeforeEach
+    void setUp() {
+        dingTalkClient = mock(DDClient.class);
+        dingTalkApprovalClient = mock(DingTalkApprovalClient.class);
+        applications = new YidaApplicationsProperties();
+        applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
+        applications.getCoordinationOffice().setSystemToken("system-token");
+        applications.getCoordinationOffice().setProcessInstanceQueryUserId("41603502201288023");
+        client = new YidaProcessInstanceClientImpl(dingTalkClient, dingTalkApprovalClient, applications);
+    }
+
+    @Test
+    void getsProcessDetailThroughDingTalkOpenApi() {
+        Map<String, Object> expected = new LinkedHashMap<String, Object>();
+        expected.put("formUuid", "FORM-001");
+        expected.put("data", new LinkedHashMap<String, Object>());
+        when(dingTalkClient.getAccessToken()).thenReturn("access-token");
+        when(dingTalkApprovalClient.getYidaProcessInstanceDetail("access-token", "process-001",
+                "APP_RJJL69QUIZVQSV7YC8TL", "system-token", "41603502201288023"))
+                .thenReturn(expected);
+
+        Map<String, Object> response = client.getProcessInstance("process-001");
+
+        assertEquals(expected, response);
+        verify(dingTalkApprovalClient).getYidaProcessInstanceDetail("access-token", "process-001",
+                "APP_RJJL69QUIZVQSV7YC8TL", "system-token", "41603502201288023");
+    }
+}