ソースを参照

接口开发10.30

ht 6 日 前
親
コミット
7425adce51

+ 21 - 0
sql/banking_payment_tracking.sql

@@ -0,0 +1,21 @@
+CREATE TABLE `banking_payment_tracking` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `business_trans_no` VARCHAR(128) NOT NULL COMMENT '银企支付单号,制单请求的businessTransNo',
+  `pay_amount` DECIMAL(18,2) NOT NULL COMMENT '支付金额',
+  `payment_account_no` VARCHAR(128) DEFAULT NULL COMMENT '付款账号',
+  `customerAccountNo` VARCHAR(255) DEFAULT NULL COMMENT '收款账号',
+  `customerName` VARCHAR(255) DEFAULT NULL COMMENT '收款方名称',
+  `customerBank` VARCHAR(255) DEFAULT NULL COMMENT '收款方银行名称',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '付款说明',
+  `payment_status` VARCHAR(64) DEFAULT NULL COMMENT '银企返回的支付状态,例如X0000',
+  `tracking_status` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '补偿状态:PENDING、RECEIPT_PENDING、COMPLETED',
+  `form_id` VARCHAR(128) DEFAULT NULL COMMENT '宜搭表单ID,来源于formID',
+  `form_instance_id` VARCHAR(128) DEFAULT NULL COMMENT '宜搭表单实例ID',
+  `receipt_preview_url` VARCHAR(1024) DEFAULT NULL COMMENT '回单预览地址',
+  `receipt_download_url` VARCHAR(1024) DEFAULT NULL COMMENT '回单下载地址',
+  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_banking_payment_tracking_business_trans_no` (`business_trans_no`),
+  KEY `idx_banking_payment_tracking_status` (`tracking_status`, `id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='银企支付状态与电子回单补偿记录';

+ 5 - 0
sql/banking_payment_tracking_add_matching_fields.sql

@@ -0,0 +1,5 @@
+ALTER TABLE banking_payment_tracking
+    ADD COLUMN customerAccountNo VARCHAR(255) DEFAULT NULL COMMENT '收款账号' AFTER payment_account_no,
+    ADD COLUMN customerName VARCHAR(255) DEFAULT NULL COMMENT '收款方名称' AFTER customerAccountNo,
+    ADD COLUMN customerBank VARCHAR(255) DEFAULT NULL COMMENT '收款方银行名称' AFTER customerName,
+    ADD COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT '付款说明' AFTER customerBank;

+ 30 - 0
src/main/java/com/malk/minglei/config/BankingPaymentTrackingProperties.java

@@ -0,0 +1,30 @@
+package com.malk.minglei.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * 银企支付状态补偿任务的数据存储与调度配置。
+ */
+@ConfigurationProperties(prefix = "minglei.banking.pending-payment")
+public class BankingPaymentTrackingProperties {
+
+    private String tableName = "banking_payment_tracking";
+    private long scheduleFixedDelayMillis = 300000L;
+
+    public String getTableName() {
+        return tableName;
+    }
+
+    public void setTableName(String tableName) {
+        this.tableName = tableName;
+    }
+
+    public long getScheduleFixedDelayMillis() {
+        return scheduleFixedDelayMillis;
+    }
+
+    public void setScheduleFixedDelayMillis(long scheduleFixedDelayMillis) {
+        this.scheduleFixedDelayMillis = scheduleFixedDelayMillis;
+    }
+
+}

+ 46 - 0
src/main/java/com/malk/minglei/dto/banking/PendingBankingPayment.java

@@ -0,0 +1,46 @@
+package com.malk.minglei.dto.banking;
+
+import java.math.BigDecimal;
+
+/**
+ * 待补偿查询的银企支付记录。
+ */
+public class PendingBankingPayment {
+    private Long id;
+    private String businessTransNo;
+    private BigDecimal payAmount;
+    private String paymentAccountNo;
+    private String customerAccountNo;
+    private String customerName;
+    private String customerBank;
+    private String remake;
+    private String paymentStatus;
+    private String trackingStatus;
+    private String formID;
+    private String formInstanceId;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getBusinessTransNo() { return businessTransNo; }
+    public void setBusinessTransNo(String businessTransNo) { this.businessTransNo = businessTransNo; }
+    public BigDecimal getPayAmount() { return payAmount; }
+    public void setPayAmount(BigDecimal payAmount) { this.payAmount = payAmount; }
+    public String getPaymentAccountNo() { return paymentAccountNo; }
+    public void setPaymentAccountNo(String paymentAccountNo) { this.paymentAccountNo = paymentAccountNo; }
+    public String getCustomerAccountNo() { return customerAccountNo; }
+    public void setCustomerAccountNo(String customerAccountNo) { this.customerAccountNo = customerAccountNo; }
+    public String getCustomerName() { return customerName; }
+    public void setCustomerName(String customerName) { this.customerName = customerName; }
+    public String getCustomerBank() { return customerBank; }
+    public void setCustomerBank(String customerBank) { this.customerBank = customerBank; }
+    public String getRemake() { return remake; }
+    public void setRemake(String remake) { this.remake = remake; }
+    public String getPaymentStatus() { return paymentStatus; }
+    public void setPaymentStatus(String paymentStatus) { this.paymentStatus = paymentStatus; }
+    public String getTrackingStatus() { return trackingStatus; }
+    public void setTrackingStatus(String trackingStatus) { this.trackingStatus = trackingStatus; }
+    public String getFormID() { return formID; }
+    public void setFormID(String formID) { this.formID = formID; }
+    public String getFormInstanceId() { return formInstanceId; }
+    public void setFormInstanceId(String formInstanceId) { this.formInstanceId = formInstanceId; }
+}

+ 255 - 0
src/main/java/com/malk/minglei/service/impl/banking/BankingPaymentStatusScheduler.java

@@ -0,0 +1,255 @@
+package com.malk.minglei.service.impl.banking;
+
+import com.malk.minglei.config.BankingProperties;
+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.PendingBankingPayment;
+import com.malk.minglei.service.banking.BankingService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.web.util.UriUtils;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 定时补偿查询异步支付状态,并在支付成功后下载电子回单。
+ */
+@Component
+public class BankingPaymentStatusScheduler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(BankingPaymentStatusScheduler.class);
+    private static final String PAYMENT_SUCCESS_STATUS = "X0000";
+    private static final int TRANSACTION_LOOKBACK_DAYS = 7;
+    private static final DateTimeFormatter ACCOUNTING_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+    private static final DateTimeFormatter UPDATED_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private final BankingPaymentTrackingRepository trackingRepository;
+    private final BankingService bankingService;
+    private final BankingProperties bankingProperties;
+    private final BankingPaymentYidaWriteBackService yidaWriteBackService;
+
+    public BankingPaymentStatusScheduler(BankingPaymentTrackingRepository trackingRepository,
+                                         BankingService bankingService,
+                                         BankingProperties bankingProperties,
+                                         BankingPaymentYidaWriteBackService yidaWriteBackService) {
+        this.trackingRepository = trackingRepository;
+        this.bankingService = bankingService;
+        this.bankingProperties = bankingProperties;
+        this.yidaWriteBackService = yidaWriteBackService;
+    }
+
+    /**
+     * 每五分钟查询未完成支付;支付已成功而回单未生成时只重试回单下载。
+     */
+    @Scheduled(fixedDelayString = "${minglei.banking.pending-payment.schedule-fixed-delay-millis:300000}")
+    public void queryOutstandingPaymentStatuses() {
+        LOGGER.info("Banking payment status scheduler triggered");
+        if (!trackingRepository.isAvailable()) {
+            LOGGER.warn("Banking payment status scheduler skipped: employee roster datasource is unavailable");
+            return;
+        }
+        List<PendingBankingPayment> payments = trackingRepository.findOutstandingPayments();
+        LOGGER.info("Banking payment status scheduler found {} outstanding payment(s)", payments.size());
+        for (PendingBankingPayment payment : payments) {
+            processPayment(payment);
+        }
+    }
+
+    private void processPayment(PendingBankingPayment payment) {
+        try {
+            if (!hasText(payment.getPaymentAccountNo())) {
+                LOGGER.warn("Banking payment skipped because payment account is empty businessTransNo={}",
+                        payment.getBusinessTransNo());
+                return;
+            }
+
+            Map<String, Object> transactionResponse = bankingService.queryTransactions(
+                    buildTransactionQuery(payment.getPaymentAccountNo()));
+            Map<String, Object> matchedTransaction = findMatchingTransaction(transactionResponse, payment);
+            if (matchedTransaction == null) {
+                LOGGER.info("No matching banking transaction found businessTransNo={} paymentAccountNo={}",
+                        payment.getBusinessTransNo(), payment.getPaymentAccountNo());
+                return;
+            }
+
+            String uniqueNumber = extractPaymentDataText(matchedTransaction, "uniqueNumber");
+            if (!hasText(uniqueNumber)) {
+                LOGGER.warn("Matching banking transaction has no uniqueNumber businessTransNo={}",
+                        payment.getBusinessTransNo());
+                return;
+            }
+            downloadReceipt(payment, uniqueNumber);
+        } catch (RuntimeException exception) {
+            trackingRepository.recordQueryFailure(payment.getBusinessTransNo());
+            LOGGER.error("Banking transaction compensation failed businessTransNo={}",
+                    payment.getBusinessTransNo(), exception);
+        }
+    }
+
+    private BankTransactionQueryRequest buildTransactionQuery(String paymentAccountNo) {
+        LocalDate today = LocalDate.now();
+        LocalDate fromDate = today.minusDays(TRANSACTION_LOOKBACK_DAYS);
+        LocalDateTime now = LocalDateTime.now();
+        BankTransactionQueryRequest request = new BankTransactionQueryRequest();
+        request.setAccountNo(paymentAccountNo);
+        request.setFromDateId(fromDate.format(ACCOUNTING_DATE_FORMATTER));
+        request.setToDateId(today.format(ACCOUNTING_DATE_FORMATTER));
+        request.setFromUpdatedTime(fromDate.atStartOfDay().format(UPDATED_TIME_FORMATTER));
+        request.setToUpdatedTime(now.format(UPDATED_TIME_FORMATTER));
+        request.setPageNow("1");
+        request.setPageSize("100");
+        return request;
+    }
+
+    private Map<String, Object> findMatchingTransaction(Map<String, Object> response,
+                                                        PendingBankingPayment payment) {
+        for (Map<String, Object> transaction : extractTransactionRecords(response)) {
+            if (matchesTransaction(transaction, payment)) {
+                return transaction;
+            }
+        }
+        return null;
+    }
+
+    private boolean matchesTransaction(Map<String, Object> transaction, PendingBankingPayment payment) {
+        return amountsEqual(transaction.get("drAmount"), payment.getPayAmount())
+                && equalsText(transaction.get("accountNo"), payment.getPaymentAccountNo())
+                && equalsText(transaction.get("customerAccountNo"), payment.getCustomerAccountNo())
+                && equalsText(transaction.get("customerName"), payment.getCustomerName())
+                && matchesText(transaction.get("customerBank"), payment.getCustomerBank())
+                && equalsText(transaction.get("userMemo"), payment.getRemake());
+    }
+
+    private boolean amountsEqual(Object actualValue, BigDecimal expectedValue) {
+        if (actualValue == null || expectedValue == null) {
+            return false;
+        }
+        try {
+            return new BigDecimal(String.valueOf(actualValue)).compareTo(expectedValue) == 0;
+        } catch (NumberFormatException exception) {
+            return false;
+        }
+    }
+
+    private boolean matchesText(Object actualValue, String expectedValue) {
+        if (!hasText(expectedValue) || actualValue == null || !hasText(String.valueOf(actualValue))) {
+            return false;
+        }
+        String actual = normalizeText(String.valueOf(actualValue));
+        String expected = normalizeText(expectedValue);
+        return actual.equals(expected) || actual.contains(expected) || expected.contains(actual);
+    }
+
+    private boolean equalsText(Object actualValue, String expectedValue) {
+        if (!hasText(expectedValue) || actualValue == null || !hasText(String.valueOf(actualValue))) {
+            return false;
+        }
+        return normalizeText(String.valueOf(actualValue)).equals(normalizeText(expectedValue));
+    }
+
+    private String normalizeText(String value) {
+        return value == null ? "" : value.replaceAll("\\s+", "").trim().toLowerCase();
+    }
+
+    private List<Map<String, Object>> extractTransactionRecords(Map<String, Object> response) {
+        if (response == null) {
+            return Collections.emptyList();
+        }
+        List<Map<String, Object>> records = new ArrayList<Map<String, Object>>();
+        collectTransactionRecords(response.get("data"), records);
+        if (records.isEmpty()) {
+            collectTransactionRecords(response, records);
+        }
+        return records;
+    }
+
+    @SuppressWarnings("unchecked")
+    private void collectTransactionRecords(Object value, List<Map<String, Object>> records) {
+        if (value instanceof List) {
+            List<?> list = (List<?>) value;
+            if (isTransactionList(list)) {
+                for (Object item : list) {
+                    records.add((Map<String, Object>) item);
+                }
+                return;
+            }
+            for (Object item : list) {
+                collectTransactionRecords(item, records);
+            }
+            return;
+        }
+        if (value instanceof Map) {
+            for (Object child : ((Map<?, ?>) value).values()) {
+                collectTransactionRecords(child, records);
+            }
+        }
+    }
+
+    private boolean isTransactionList(List<?> list) {
+        if (list.isEmpty()) {
+            return false;
+        }
+        for (Object item : list) {
+            if (item instanceof Map && (((Map<?, ?>) item).containsKey("uniqueNumber")
+                    || ((Map<?, ?>) item).containsKey("drAmount"))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void downloadReceipt(PendingBankingPayment payment, String uniqueNumber) {
+        String businessTransNo = payment.getBusinessTransNo();
+        try {
+            ElectronicBillDownloadRequest request = new ElectronicBillDownloadRequest();
+            if (uniqueNumber != null && !uniqueNumber.trim().isEmpty()) {
+                request.setUniqueNumber(uniqueNumber);
+            } else {
+                request.setBusinessTransNo(businessTransNo);
+            }
+            ElectronicBillDownloadResult receipt = bankingService.downloadElectronicBill(request);
+            ElectronicBillDownloadResult receiptWithUrls = new ElectronicBillDownloadResult(
+                    receipt.getFileName(), receipt.getFilePath(), receipt.getFileSize(), receipt.getContentType(),
+                    buildReceiptUrl(receipt.getFileName(), "preview"), buildReceiptUrl(receipt.getFileName(), "download"));
+            yidaWriteBackService.writePaymentResult(payment.getFormID(), payment.getFormInstanceId(),
+                    PAYMENT_SUCCESS_STATUS, receiptWithUrls);
+            trackingRepository.markCompleted(businessTransNo,
+                    receiptWithUrls.getPreviewUrl(), receiptWithUrls.getDownloadUrl());
+            LOGGER.info("Banking electronic bill downloaded businessTransNo={} fileName={}", businessTransNo,
+                    receipt.getFileName());
+        } catch (RuntimeException exception) {
+            trackingRepository.markReceiptDownloadFailed(businessTransNo);
+            // 回单下载、文件保存和宜搭链接回写任一环节失败时均保留重试状态,日志必须包含原因以便排查。
+            LOGGER.warn("Banking payment succeeded but electronic bill is unavailable businessTransNo={}",
+                    businessTransNo, exception);
+        }
+    }
+
+    private String extractPaymentDataText(Map<String, Object> response, String fieldName) {
+        Object value = response == null ? null : response.get(fieldName);
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private boolean hasText(String value) {
+        return value != null && !value.trim().isEmpty();
+    }
+
+    private String buildReceiptUrl(String fileName, String option) {
+        return bankingProperties.getPublicBaseUrl().replaceAll("/+$", "")
+                + "/api/banking/electronic-bills/files/"
+                + UriUtils.encodePathSegment(fileName, StandardCharsets.UTF_8)
+                + "?option=" + option;
+    }
+
+}

+ 168 - 0
src/main/java/com/malk/minglei/service/impl/banking/BankingPaymentTrackingRepository.java

@@ -0,0 +1,168 @@
+package com.malk.minglei.service.impl.banking;
+
+import com.malk.minglei.config.BankingPaymentTrackingProperties;
+import com.malk.minglei.dto.banking.PendingBankingPayment;
+import com.malk.minglei.dto.banking.SinglePaymentRequest;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+/**
+ * 维护银企支付状态补偿记录,保证支付状态与回单下载可在异步场景持续追踪。
+ */
+@Repository
+public class BankingPaymentTrackingRepository {
+
+    public static final String TRACKING_STATUS_PENDING = "PENDING";
+    public static final String TRACKING_STATUS_RECEIPT_PENDING = "RECEIPT_PENDING";
+    public static final String TRACKING_STATUS_COMPLETED = "COMPLETED";
+
+    private final ObjectProvider<JdbcTemplate> jdbcTemplateProvider;
+    private final BankingPaymentTrackingProperties properties;
+
+    public BankingPaymentTrackingRepository(
+            @Qualifier("employeeRosterJdbcTemplate") ObjectProvider<JdbcTemplate> jdbcTemplateProvider,
+            BankingPaymentTrackingProperties properties) {
+        this.jdbcTemplateProvider = jdbcTemplateProvider;
+        this.properties = properties;
+    }
+
+    /**
+     * 判断支付跟踪数据库是否已配置。
+     */
+    public boolean isAvailable() {
+        return jdbcTemplateProvider.getIfAvailable() != null;
+    }
+
+    /**
+     * 制单成功后写入待补偿记录;同一支付单号重复提交时保留原有处理状态。
+     */
+    public void saveSubmittedPayment(SinglePaymentRequest request) {
+        String sql = "INSERT INTO " + tableName() + " "
+                + "(business_trans_no, pay_amount, payment_account_no, customerAccountNo, customerName, "
+                + "customerBank, remark, payment_status, tracking_status, form_id, form_instance_id) "
+                + "VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?) "
+                + "ON DUPLICATE KEY UPDATE pay_amount = VALUES(pay_amount), "
+                + "payment_account_no = VALUES(payment_account_no), "
+                + "customerAccountNo = VALUES(customerAccountNo), "
+                + "customerName = VALUES(customerName), "
+                + "customerBank = VALUES(customerBank), "
+                + "remark = VALUES(remark), "
+                + "form_id = COALESCE(VALUES(form_id), form_id), "
+                + "form_instance_id = COALESCE(VALUES(form_instance_id), form_instance_id), updated_at = CURRENT_TIMESTAMP";
+        jdbcTemplate().update(sql, request.getBusinessTransNo(), request.getPayAmount(),
+                emptyToNull(request.getFromAccountNo()), emptyToNull(request.getToAccountNo()),
+                emptyToNull(request.getToAccountName()), emptyToNull(request.getToBankName()),
+                emptyToNull(resolveRemark(request)), TRACKING_STATUS_PENDING,
+                emptyToNull(request.getFormID()), emptyToNull(request.getFormInstanceId()));
+    }
+
+    /**
+     * 查询仍需查询支付状态或重试下载回单的支付记录。
+     */
+    public List<PendingBankingPayment> findOutstandingPayments() {
+        String sql = "SELECT id, business_trans_no, pay_amount, payment_account_no, customerAccountNo, "
+                + "customerName, customerBank, remark, payment_status, tracking_status, form_id, form_instance_id "
+                + "FROM " + tableName() + " WHERE tracking_status IN (?, ?) ORDER BY id";
+        return jdbcTemplate().query(sql, preparedStatement -> {
+                    preparedStatement.setString(1, TRACKING_STATUS_PENDING);
+                    preparedStatement.setString(2, TRACKING_STATUS_RECEIPT_PENDING);
+                }, (resultSet, rowNum) -> {
+                    PendingBankingPayment payment = new PendingBankingPayment();
+                    payment.setId(Long.valueOf(resultSet.getLong("id")));
+                    payment.setBusinessTransNo(resultSet.getString("business_trans_no"));
+                    payment.setPayAmount(resultSet.getBigDecimal("pay_amount"));
+                    payment.setPaymentAccountNo(resultSet.getString("payment_account_no"));
+                    payment.setCustomerAccountNo(resultSet.getString("customerAccountNo"));
+                    payment.setCustomerName(resultSet.getString("customerName"));
+                    payment.setCustomerBank(resultSet.getString("customerBank"));
+                    payment.setRemake(resultSet.getString("remark"));
+                    payment.setPaymentStatus(resultSet.getString("payment_status"));
+                    payment.setTrackingStatus(resultSet.getString("tracking_status"));
+                    payment.setFormID(resultSet.getString("form_id"));
+                    payment.setFormInstanceId(resultSet.getString("form_instance_id"));
+                    return payment;
+                });
+    }
+
+    /**
+     * 保存一次成功的支付状态查询结果。
+     */
+    public void recordPaymentStatus(String businessTransNo, String paymentStatus) {
+        String sql = "UPDATE " + tableName() + " SET payment_status = ?, updated_at = CURRENT_TIMESTAMP "
+                + "WHERE business_trans_no = ? AND tracking_status <> ?";
+        jdbcTemplate().update(sql, emptyToNull(paymentStatus), businessTransNo, TRACKING_STATUS_COMPLETED);
+    }
+
+    /**
+     * 支付已成功但回单暂不可下载时保留记录,以便下一轮任务重试回单下载。
+     */
+    public void markReceiptDownloadFailed(String businessTransNo) {
+        String sql = "UPDATE " + tableName() + " SET tracking_status = ?, updated_at = CURRENT_TIMESTAMP "
+                + "WHERE business_trans_no = ? AND tracking_status <> ?";
+        jdbcTemplate().update(sql, TRACKING_STATUS_RECEIPT_PENDING, businessTransNo,
+                TRACKING_STATUS_COMPLETED);
+    }
+
+    /**
+     * 保存已下载回单的文件位置和外部访问链接,并结束补偿任务。
+     */
+    public void markCompleted(String businessTransNo, String previewUrl, String downloadUrl) {
+        String sql = "UPDATE " + tableName() + " SET payment_status = 'X0000', tracking_status = ?, "
+                + "receipt_preview_url = ?, receipt_download_url = ?, updated_at = CURRENT_TIMESTAMP "
+                + "WHERE business_trans_no = ?";
+        jdbcTemplate().update(sql, TRACKING_STATUS_COMPLETED, previewUrl, downloadUrl, businessTransNo);
+    }
+
+    /**
+     * 支付进入不可继续处理的失败终态时结束状态补偿。
+     */
+    public void markTerminalFailure(String businessTransNo, String paymentStatus) {
+        String sql = "UPDATE " + tableName() + " SET payment_status = ?, tracking_status = ?, "
+                + "updated_at = CURRENT_TIMESTAMP WHERE business_trans_no = ?";
+        jdbcTemplate().update(sql, emptyToNull(paymentStatus), TRACKING_STATUS_COMPLETED, businessTransNo);
+    }
+
+    /**
+     * 查询异常时更新时间,记录会在下一轮任务继续重试。
+     */
+    public void recordQueryFailure(String businessTransNo) {
+        String sql = "UPDATE " + tableName() + " SET updated_at = CURRENT_TIMESTAMP "
+                + "WHERE business_trans_no = ? AND tracking_status <> ?";
+        jdbcTemplate().update(sql, businessTransNo, TRACKING_STATUS_COMPLETED);
+    }
+
+    private JdbcTemplate jdbcTemplate() {
+        JdbcTemplate jdbcTemplate = jdbcTemplateProvider.getIfAvailable();
+        if (jdbcTemplate == null) {
+            throw new IllegalStateException("MINGLEI_EMPLOYEE_ROSTER_DATASOURCE_URL is not configured");
+        }
+        return jdbcTemplate;
+    }
+
+    private String tableName() {
+        String tableName = properties.getTableName();
+        if (tableName == null || !tableName.matches("[A-Za-z][A-Za-z0-9_]*")) {
+            throw new IllegalStateException("minglei.banking.pending-payment.table-name must be a simple SQL identifier");
+        }
+        return tableName;
+    }
+
+    private String emptyToNull(String value) {
+        return value == null || value.trim().isEmpty() ? null : value;
+    }
+
+    private String resolveRemark(SinglePaymentRequest request) {
+        if (request.getPostscript() != null && !request.getPostscript().trim().isEmpty()) {
+            return request.getPostscript();
+        }
+        if (request.getSummary() != null && !request.getSummary().trim().isEmpty()) {
+            return request.getSummary();
+        }
+        return request.getPurpose();
+    }
+
+}

+ 183 - 0
src/main/java/com/malk/minglei/service/impl/banking/BankingPaymentYidaWriteBackService.java

@@ -0,0 +1,183 @@
+package com.malk.minglei.service.impl.banking;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadResult;
+import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
+import com.malk.server.dingtalk.DDR_New;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 将银企支付状态和电子回单链接回写至宜搭付款表单。
+ */
+@Service
+public class BankingPaymentYidaWriteBackService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(BankingPaymentYidaWriteBackService.class);
+    private static final String PAYMENT_STATUS_FIELD_ID = "selectField_mvq9jruo";
+    private static final String PAYMENT_MESSAGE_FIELD_ID = "textField_mvq9jrup";
+    private static final String RECEIPT_ATTACHMENT_FIELD_ID = "attachmentField_mvq9jruq";
+    private static final String ORIGINAL_PAYMENT_FORM_UUID = "FORM-248E559E92394069B7A7B08EA859EFE1HGAD";
+    private static final String MATERIAL_PAYMENT_FORM_UUID = "FORM-5EF5C90019AE4E5AA1AFB3FC6572A7E63KRY";
+    private static final String EXPENSE_REIMBURSEMENT_FORM_UUID = "FORM-9785C335A34449B3B806DAF63B6C5AB7PRH1";
+    private static final PaymentFieldMapping ORIGINAL_PAYMENT_FIELDS = new PaymentFieldMapping(
+            PAYMENT_STATUS_FIELD_ID, PAYMENT_MESSAGE_FIELD_ID, RECEIPT_ATTACHMENT_FIELD_ID);
+    private static final PaymentFieldMapping MATERIAL_PAYMENT_FIELDS = new PaymentFieldMapping(
+            "selectField_mvrimme0", "textField_mvrimmdx", "attachmentField_mvrimmdz");
+    private static final PaymentFieldMapping EXPENSE_REIMBURSEMENT_FIELDS = new PaymentFieldMapping(
+            "selectField_mvrj87p7", "textField_mvrj87p8", "attachmentField_mvrj87p9");
+
+    private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
+    private final YidaApplicationsProperties.ApplicationCredentials coordinationOfficeConfiguration;
+    private final ObjectMapper objectMapper;
+
+    public BankingPaymentYidaWriteBackService(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                              YidaApplicationsProperties applications,
+                                              ObjectMapper objectMapper) {
+        this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
+        this.coordinationOfficeConfiguration = applications.getCoordinationOffice();
+        this.objectMapper = objectMapper;
+    }
+
+    /**
+     * 回写支付状态;未关联宜搭表单的支付单不执行回写。
+     */
+    public void writePaymentResult(String formID, String formInstanceId, String bankPaymentStatus,
+                                   ElectronicBillDownloadResult receipt) {
+        if (!StringUtils.hasText(formID) || !StringUtils.hasText(formInstanceId)) {
+            return;
+        }
+        PaymentResult paymentResult = PaymentResult.of(bankPaymentStatus);
+        PaymentFieldMapping fieldMapping = resolveFieldMapping(formID);
+        Map<String, Object> fields = new LinkedHashMap<String, Object>();
+        fields.put(fieldMapping.paymentStatusFieldId, paymentResult.yidaStatus);
+        fields.put(fieldMapping.paymentMessageFieldId, paymentResult.message);
+        if (receipt != null) {
+            fields.put(fieldMapping.receiptAttachmentFieldId,
+                    Collections.singletonList(buildReceiptAttachment(receipt)));
+        }
+
+        Map<String, Object> body = new LinkedHashMap<String, Object>();
+        body.put("appType", requiredText(coordinationOfficeConfiguration.getAppType(),
+                "YIDA_COORDINATION_OFFICE_APP_TYPE is not configured"));
+        body.put("systemToken", requiredText(coordinationOfficeConfiguration.getSystemToken(),
+                "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured"));
+        body.put("formUuid", formID);
+        body.put("formInstanceId", formInstanceId);
+        body.put("updateFormDataJson", toJson(fields));
+
+        DDR_New response = yidaProcessOpenApiClient.updateProcessInstance(body);
+        if (response == null || !response.isSuccess()) {
+            String message = response == null ? "响应为空" : defaultText(response.getMessage(), "宜搭拒绝更新");
+            throw new IllegalStateException("宜搭付款状态回写失败:" + message);
+        }
+        LOGGER.info("Banking payment result written back to Yida formID={} formInstanceId={} bankPaymentStatus={}",
+                formID, formInstanceId, bankPaymentStatus);
+    }
+
+    /**
+     * 根据宜搭表单 UUID 选择字段映射,避免不同表单的同义字段互相覆盖。
+     */
+    private PaymentFieldMapping resolveFieldMapping(String formID) {
+        if (MATERIAL_PAYMENT_FORM_UUID.equals(formID)) {
+            return MATERIAL_PAYMENT_FIELDS;
+        }
+        if (EXPENSE_REIMBURSEMENT_FORM_UUID.equals(formID)) {
+            return EXPENSE_REIMBURSEMENT_FIELDS;
+        }
+        if (ORIGINAL_PAYMENT_FORM_UUID.equals(formID)) {
+            return ORIGINAL_PAYMENT_FIELDS;
+        }
+        // 保留旧调用方的兼容行为;新表单接入时应先登记对应字段映射。
+        return ORIGINAL_PAYMENT_FIELDS;
+    }
+
+    /**
+     * 构造宜搭 AttachmentField 需要的文件对象。回单保存在本服务,因此附件链接指向本服务的预览和下载接口。
+     */
+    private Map<String, Object> buildReceiptAttachment(ElectronicBillDownloadResult receipt) {
+        if (!StringUtils.hasText(receipt.getFileName()) || !StringUtils.hasText(receipt.getPreviewUrl())
+                || !StringUtils.hasText(receipt.getDownloadUrl())) {
+            throw new IllegalArgumentException("回单附件名称、预览链接和下载链接不能为空");
+        }
+        Map<String, Object> attachment = new LinkedHashMap<String, Object>();
+        attachment.put("name", receipt.getFileName());
+        attachment.put("size", receipt.getFileSize());
+        attachment.put("fileUuid", "banking-receipt-" + receipt.getFileName());
+        attachment.put("url", receipt.getDownloadUrl());
+        attachment.put("downloadUrl", receipt.getDownloadUrl());
+        attachment.put("previewUrl", receipt.getPreviewUrl());
+        attachment.put("ext", fileExtension(receipt.getFileName()));
+        return attachment;
+    }
+
+    private String fileExtension(String fileName) {
+        int extensionIndex = fileName.lastIndexOf('.');
+        return extensionIndex > -1 && extensionIndex < fileName.length() - 1
+                ? fileName.substring(extensionIndex + 1) : "";
+    }
+
+    private String toJson(Map<String, Object> fields) {
+        try {
+            return objectMapper.writeValueAsString(fields);
+        } catch (JsonProcessingException exception) {
+            throw new IllegalStateException("宜搭付款状态回写数据序列化失败", exception);
+        }
+    }
+
+    private String requiredText(String value, String message) {
+        if (!StringUtils.hasText(value)) {
+            throw new IllegalStateException(message);
+        }
+        return value;
+    }
+
+    private String defaultText(String value, String fallback) {
+        return StringUtils.hasText(value) ? value : fallback;
+    }
+
+    private static class PaymentFieldMapping {
+        private final String paymentStatusFieldId;
+        private final String paymentMessageFieldId;
+        private final String receiptAttachmentFieldId;
+
+        private PaymentFieldMapping(String paymentStatusFieldId, String paymentMessageFieldId,
+                                    String receiptAttachmentFieldId) {
+            this.paymentStatusFieldId = paymentStatusFieldId;
+            this.paymentMessageFieldId = paymentMessageFieldId;
+            this.receiptAttachmentFieldId = receiptAttachmentFieldId;
+        }
+    }
+
+    /**
+     * 将银企状态码转换为宜搭付款状态和可读说明。
+     */
+    private static class PaymentResult {
+        private final String yidaStatus;
+        private final String message;
+
+        private PaymentResult(String yidaStatus, String message) {
+            this.yidaStatus = yidaStatus;
+            this.message = message;
+        }
+
+        private static PaymentResult of(String bankPaymentStatus) {
+            if ("X0000".equals(bankPaymentStatus)) return new PaymentResult("付款成功", "支付成功");
+            if ("X5554".equals(bankPaymentStatus)) return new PaymentResult("付款失败", "支付单不存在");
+            if ("X5555".equals(bankPaymentStatus)) return new PaymentResult("待付款", "银行处理中");
+            if ("X5556".equals(bankPaymentStatus)) return new PaymentResult("待付款", "支付结果未知");
+            if ("X9997".equals(bankPaymentStatus)) return new PaymentResult("付款失败", "已退款");
+            if ("X9999".equals(bankPaymentStatus)) return new PaymentResult("付款失败", "支付失败");
+            return new PaymentResult("待付款", StringUtils.hasText(bankPaymentStatus)
+                    ? "银企返回状态:" + bankPaymentStatus : "暂未返回支付状态");
+        }
+    }
+}

+ 141 - 0
src/test/java/com/malk/minglei/service/impl/banking/BankingPaymentStatusSchedulerTest.java

@@ -0,0 +1,141 @@
+package com.malk.minglei.service.impl.banking;
+
+import com.malk.minglei.config.BankingProperties;
+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.PendingBankingPayment;
+import com.malk.minglei.service.banking.BankingService;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+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.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+class BankingPaymentStatusSchedulerTest {
+
+    @Test
+    void matchesTransactionThenDownloadsReceiptByUniqueNumberAndCompletesTracking() {
+        BankingPaymentTrackingRepository repository = mock(BankingPaymentTrackingRepository.class);
+        BankingService bankingService = mock(BankingService.class);
+        BankingPaymentYidaWriteBackService yidaWriteBackService = mock(BankingPaymentYidaWriteBackService.class);
+        BankingPaymentStatusScheduler scheduler = new BankingPaymentStatusScheduler(repository, bankingService,
+                properties(), yidaWriteBackService);
+        PendingBankingPayment payment = payment("PAY-1", BankingPaymentTrackingRepository.TRACKING_STATUS_PENDING);
+        payment.setFormID("FORM-1");
+        payment.setFormInstanceId("INSTANCE-1");
+        given(repository.isAvailable()).willReturn(true);
+        given(repository.findOutstandingPayments()).willReturn(Collections.singletonList(payment));
+        given(bankingService.queryTransactions(any(BankTransactionQueryRequest.class)))
+                .willReturn(transactionResponse("UNIQUE-1"));
+        given(bankingService.downloadElectronicBill(any(ElectronicBillDownloadRequest.class)))
+                .willReturn(new ElectronicBillDownloadResult("PAY-1.pdf", "/minglei/pay_file/PAY-1.pdf",
+                        1024L, "application/pdf"));
+
+        scheduler.queryOutstandingPaymentStatuses();
+
+        ArgumentCaptor<ElectronicBillDownloadRequest> requestCaptor =
+                ArgumentCaptor.forClass(ElectronicBillDownloadRequest.class);
+        verify(bankingService).downloadElectronicBill(requestCaptor.capture());
+        assertEquals("UNIQUE-1", requestCaptor.getValue().getUniqueNumber());
+        verify(yidaWriteBackService).writePaymentResult(eq("FORM-1"), eq("INSTANCE-1"), eq("X0000"),
+                any(ElectronicBillDownloadResult.class));
+        verify(repository).markCompleted(eq("PAY-1"),
+                eq("http://example.test/api/banking/electronic-bills/files/PAY-1.pdf?option=preview"),
+                eq("http://example.test/api/banking/electronic-bills/files/PAY-1.pdf?option=download"));
+        verify(bankingService, never()).queryPaymentStatus(any(String.class));
+    }
+
+    @Test
+    void retriesReceiptPendingRecordWithTheLatestMatchingTransaction() {
+        BankingPaymentTrackingRepository repository = mock(BankingPaymentTrackingRepository.class);
+        BankingService bankingService = mock(BankingService.class);
+        BankingPaymentYidaWriteBackService yidaWriteBackService = mock(BankingPaymentYidaWriteBackService.class);
+        BankingPaymentStatusScheduler scheduler = new BankingPaymentStatusScheduler(repository, bankingService,
+                properties(), yidaWriteBackService);
+        PendingBankingPayment payment = payment("PAY-2", BankingPaymentTrackingRepository.TRACKING_STATUS_RECEIPT_PENDING);
+        payment.setPaymentStatus("X0000");
+        given(repository.isAvailable()).willReturn(true);
+        given(repository.findOutstandingPayments()).willReturn(Collections.singletonList(payment));
+        given(bankingService.queryTransactions(any(BankTransactionQueryRequest.class)))
+                .willReturn(transactionResponse("UNIQUE-2"));
+        given(bankingService.downloadElectronicBill(any(ElectronicBillDownloadRequest.class)))
+                .willReturn(new ElectronicBillDownloadResult("PAY-2.pdf", "/minglei/pay_file/PAY-2.pdf",
+                        1024L, "application/pdf"));
+
+        scheduler.queryOutstandingPaymentStatuses();
+
+        verify(bankingService).queryTransactions(any(BankTransactionQueryRequest.class));
+        verify(bankingService).downloadElectronicBill(any(ElectronicBillDownloadRequest.class));
+        verify(repository).markCompleted(eq("PAY-2"), any(String.class), any(String.class));
+    }
+
+    @Test
+    void keepsRecordPendingWhenNoTransactionMatchesAllBusinessFields() {
+        BankingPaymentTrackingRepository repository = mock(BankingPaymentTrackingRepository.class);
+        BankingService bankingService = mock(BankingService.class);
+        BankingPaymentYidaWriteBackService yidaWriteBackService = mock(BankingPaymentYidaWriteBackService.class);
+        BankingPaymentStatusScheduler scheduler = new BankingPaymentStatusScheduler(repository, bankingService,
+                properties(), yidaWriteBackService);
+        PendingBankingPayment payment = payment("PAY-3", BankingPaymentTrackingRepository.TRACKING_STATUS_PENDING);
+        given(repository.isAvailable()).willReturn(true);
+        given(repository.findOutstandingPayments()).willReturn(Collections.singletonList(payment));
+        given(bankingService.queryTransactions(any(BankTransactionQueryRequest.class)))
+                .willReturn(transactionResponse("UNIQUE-3", "different-memo"));
+
+        scheduler.queryOutstandingPaymentStatuses();
+
+        verify(bankingService, never()).downloadElectronicBill(any(ElectronicBillDownloadRequest.class));
+        verify(yidaWriteBackService, never()).writePaymentResult(any(), any(), any(), any());
+        verify(repository, never()).markCompleted(any(String.class), any(String.class), any(String.class));
+    }
+
+    private PendingBankingPayment payment(String businessTransNo, String trackingStatus) {
+        PendingBankingPayment payment = new PendingBankingPayment();
+        payment.setBusinessTransNo(businessTransNo);
+        payment.setPayAmount(new BigDecimal("1.00"));
+        payment.setPaymentAccountNo("from-account");
+        payment.setCustomerAccountNo("to-account");
+        payment.setCustomerName("recipient");
+        payment.setCustomerBank("bank");
+        payment.setRemake("memo");
+        payment.setTrackingStatus(trackingStatus);
+        return payment;
+    }
+
+    private Map<String, Object> transactionResponse(String uniqueNumber) {
+        return transactionResponse(uniqueNumber, "memo");
+    }
+
+    private Map<String, Object> transactionResponse(String uniqueNumber, String memo) {
+        Map<String, Object> transaction = new LinkedHashMap<String, Object>();
+        transaction.put("drAmount", "1.00");
+        transaction.put("accountNo", "from-account");
+        transaction.put("customerAccountNo", "to-account");
+        transaction.put("customerName", "recipient");
+        transaction.put("customerBank", "bank");
+        transaction.put("userMemo", memo);
+        transaction.put("uniqueNumber", uniqueNumber);
+        Map<String, Object> data = new LinkedHashMap<String, Object>();
+        data.put("records", Collections.singletonList(transaction));
+        Map<String, Object> response = new LinkedHashMap<String, Object>();
+        response.put("data", data);
+        return response;
+    }
+
+    private BankingProperties properties() {
+        BankingProperties properties = new BankingProperties();
+        properties.setPublicBaseUrl("http://example.test");
+        return properties;
+    }
+}

+ 109 - 0
src/test/java/com/malk/minglei/service/impl/banking/BankingPaymentYidaWriteBackServiceTest.java

@@ -0,0 +1,109 @@
+package com.malk.minglei.service.impl.banking;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.banking.ElectronicBillDownloadResult;
+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.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 验证银企支付状态按指定字段回写至宜搭付款表单。
+ */
+class BankingPaymentYidaWriteBackServiceTest {
+
+    @Test
+    void writesSuccessfulPaymentAndReceiptAttachmentToConfiguredField() throws Exception {
+        YidaProcessOpenApiClient client = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setSuccess(true);
+        given(client.updateProcessInstance(any())).willReturn(response);
+        BankingPaymentYidaWriteBackService service = new BankingPaymentYidaWriteBackService(client, applications(),
+                new ObjectMapper());
+        ElectronicBillDownloadResult receipt = new ElectronicBillDownloadResult("receipt.pdf", "/minglei/pay_file/receipt.pdf",
+                100L, "application/pdf", "http://example.test/preview", "http://example.test/download");
+
+        service.writePaymentResult("FORM-1", "INSTANCE-1", "X0000", receipt);
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(client).updateProcessInstance(bodyCaptor.capture());
+        Map<String, Object> body = bodyCaptor.getValue();
+        assertEquals("FORM-1", body.get("formUuid"));
+        assertEquals("INSTANCE-1", body.get("formInstanceId"));
+        Map<String, Object> updateData = new ObjectMapper().readValue((String) body.get("updateFormDataJson"),
+                new TypeReference<Map<String, Object>>() { });
+        assertEquals("付款成功", updateData.get("selectField_mvq9jruo"));
+        assertEquals("支付成功", updateData.get("textField_mvq9jrup"));
+        List<Map<String, Object>> attachments = (List<Map<String, Object>>) updateData.get("attachmentField_mvq9jruq");
+        assertEquals(1, attachments.size());
+        assertEquals("receipt.pdf", attachments.get(0).get("name"));
+        assertEquals(100, attachments.get(0).get("size"));
+        assertEquals("banking-receipt-receipt.pdf", attachments.get(0).get("fileUuid"));
+        assertEquals("http://example.test/preview", attachments.get(0).get("previewUrl"));
+        assertEquals("http://example.test/download", attachments.get(0).get("downloadUrl"));
+    }
+
+    @Test
+    void usesMaterialPaymentFormFieldMapping() throws Exception {
+        YidaProcessOpenApiClient client = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setSuccess(true);
+        given(client.updateProcessInstance(any())).willReturn(response);
+        BankingPaymentYidaWriteBackService service = new BankingPaymentYidaWriteBackService(client, applications(),
+                new ObjectMapper());
+        ElectronicBillDownloadResult receipt = new ElectronicBillDownloadResult("receipt.pdf", "/pay_file/receipt.pdf",
+                100L, "application/pdf", "http://example.test/preview", "http://example.test/download");
+
+        service.writePaymentResult("FORM-5EF5C90019AE4E5AA1AFB3FC6572A7E63KRY", "INSTANCE-1", "X0000", receipt);
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(client).updateProcessInstance(bodyCaptor.capture());
+        Map<String, Object> updateData = new ObjectMapper().readValue(
+                (String) bodyCaptor.getValue().get("updateFormDataJson"),
+                new TypeReference<Map<String, Object>>() { });
+        assertEquals("付款成功", updateData.get("selectField_mvrimme0"));
+        assertEquals("支付成功", updateData.get("textField_mvrimmdx"));
+        assertEquals(1, ((List<?>) updateData.get("attachmentField_mvrimmdz")).size());
+    }
+
+    @Test
+    void usesExpenseReimbursementFormFieldMapping() throws Exception {
+        YidaProcessOpenApiClient client = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setSuccess(true);
+        given(client.updateProcessInstance(any())).willReturn(response);
+        BankingPaymentYidaWriteBackService service = new BankingPaymentYidaWriteBackService(client, applications(),
+                new ObjectMapper());
+        ElectronicBillDownloadResult receipt = new ElectronicBillDownloadResult("receipt.pdf", "/pay_file/receipt.pdf",
+                100L, "application/pdf", "http://example.test/preview", "http://example.test/download");
+
+        service.writePaymentResult("FORM-9785C335A34449B3B806DAF63B6C5AB7PRH1", "INSTANCE-1", "X0000", receipt);
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(client).updateProcessInstance(bodyCaptor.capture());
+        Map<String, Object> updateData = new ObjectMapper().readValue(
+                (String) bodyCaptor.getValue().get("updateFormDataJson"),
+                new TypeReference<Map<String, Object>>() { });
+        assertEquals("付款成功", updateData.get("selectField_mvrj87p7"));
+        assertEquals("支付成功", updateData.get("textField_mvrj87p8"));
+        assertEquals(1, ((List<?>) updateData.get("attachmentField_mvrj87p9")).size());
+    }
+
+    private YidaApplicationsProperties applications() {
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getCoordinationOffice().setAppType("APP-1");
+        applications.getCoordinationOffice().setSystemToken("TOKEN-1");
+        return applications;
+    }
+}