ht пре 2 дана
родитељ
комит
0c0c7cfcdf

+ 2 - 0
README.md

@@ -346,6 +346,8 @@ Content-Type: application/json
 
 服务端将最终账户类型以字符串形式写入见知 `/api/v2/payment/commonPay` 报文;开启国密加密时,该字段包含在加密、签名的支付报文中。
 
+`businessTransNo` 仍传入原单据号。服务端会在制单前为同一原单据号追加四位递增序号,例如首次提交 `ML202611030001` 会生成并返回 `ML2026110300010001`,后续提交依次为 `...0002`、`...0003`。响应中的 `businessTransNo` 为实际发送到银企、保存到支付跟踪表,以及后续状态查询和回单下载使用的唯一流水号。
+
 ## 5. ERP 推送规则
 
 ### 5.1 支持的类型

+ 3 - 0
sql/banking_payment_tracking.sql

@@ -1,6 +1,8 @@
 CREATE TABLE `banking_payment_tracking` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
   `business_trans_no` VARCHAR(128) NOT NULL COMMENT '银企支付单号,制单请求的businessTransNo',
+  `source_business_trans_no` VARCHAR(128) NOT NULL COMMENT '业务系统传入的原单据号',
+  `payment_attempt_no` INT UNSIGNED NOT NULL COMMENT '同一原单据号的支付提交序号,从1开始',
   `pay_amount` DECIMAL(18,2) NOT NULL COMMENT '支付金额',
   `payment_account_no` VARCHAR(128) DEFAULT NULL COMMENT '付款账号',
   `customerAccountNo` VARCHAR(255) DEFAULT NULL COMMENT '收款账号',
@@ -17,5 +19,6 @@ CREATE TABLE `banking_payment_tracking` (
   `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`),
+  UNIQUE KEY `uk_banking_payment_tracking_source_attempt` (`source_business_trans_no`, `payment_attempt_no`),
   KEY `idx_banking_payment_tracking_status` (`tracking_status`, `id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='银企支付状态与电子回单补偿记录';

+ 40 - 25
src/main/java/com/malk/minglei/service/impl/banking/BankingPaymentStatusScheduler.java

@@ -30,6 +30,7 @@ public class BankingPaymentStatusScheduler {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(BankingPaymentStatusScheduler.class);
     private static final String PAYMENT_SUCCESS_STATUS = "X0000";
+    private static final String YIDA_FORM_DATA_DELETED_MESSAGE = "宜搭数据不存在或已删除";
     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");
@@ -71,9 +72,15 @@ public class BankingPaymentStatusScheduler {
             Map<String, Object> paymentStatusResponse = bankingService.queryPaymentStatus(payment.getBusinessTransNo());
             String paymentStatus = extractPaymentStatus(paymentStatusResponse);
             trackingRepository.recordPaymentStatus(payment.getBusinessTransNo(), paymentStatus);
-            boolean yidaWritten = writePaymentStatusToYida(payment, paymentStatus);
+            YidaWriteBackResult yidaWriteBackResult = writePaymentStatusToYida(payment, paymentStatus);
+            if (yidaWriteBackResult == YidaWriteBackResult.FORM_DATA_DELETED) {
+                trackingRepository.markYidaFormDataDeleted(payment.getBusinessTransNo());
+                LOGGER.warn("Banking payment processing skipped because Yida form data was deleted businessTransNo={}",
+                        payment.getBusinessTransNo());
+                return;
+            }
             if (!PAYMENT_SUCCESS_STATUS.equals(paymentStatus)) {
-                if (yidaWritten && isTerminalFailure(paymentStatus)) {
+                if (yidaWriteBackResult == YidaWriteBackResult.SUCCEEDED && isTerminalFailure(paymentStatus)) {
                     trackingRepository.markTerminalFailure(payment.getBusinessTransNo(), paymentStatus);
                 }
                 return;
@@ -111,15 +118,18 @@ public class BankingPaymentStatusScheduler {
     /**
      * 回写当前支付状态。支付成功时先回写状态,回单生成后会再次回写附件。
      */
-    private boolean writePaymentStatusToYida(PendingBankingPayment payment, String paymentStatus) {
+    private YidaWriteBackResult writePaymentStatusToYida(PendingBankingPayment payment, String paymentStatus) {
         try {
             yidaWriteBackService.writePaymentResult(payment.getFormID(), payment.getFormInstanceId(),
                     paymentStatus, null);
-            return true;
+            return YidaWriteBackResult.SUCCEEDED;
         } catch (RuntimeException exception) {
+            if (isYidaFormDataDeleted(exception)) {
+                return YidaWriteBackResult.FORM_DATA_DELETED;
+            }
             LOGGER.error("Banking payment status was not written back to Yida businessTransNo={}",
                     payment.getBusinessTransNo(), exception);
-            return false;
+            return YidaWriteBackResult.RETRY_LATER;
         }
     }
 
@@ -153,11 +163,10 @@ public class BankingPaymentStatusScheduler {
     }
 
     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())
-                && matchesBankName(transaction.get("customerBank"), payment.getCustomerBank())
                 && equalsText(transaction.get("userMemo"), payment.getRemake());
     }
 
@@ -172,18 +181,6 @@ public class BankingPaymentStatusScheduler {
         }
     }
 
-    /**
-     * 银企流水通常返回银行全称,而制单时可能仅保存简称;忽略银行主体后缀后匹配支行名称。
-     */
-    private boolean matchesBankName(Object actualValue, String expectedValue) {
-        if (!hasText(expectedValue) || actualValue == null || !hasText(String.valueOf(actualValue))) {
-            return false;
-        }
-        String actual = normalizeBankName(String.valueOf(actualValue));
-        String expected = normalizeBankName(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;
@@ -195,12 +192,6 @@ public class BankingPaymentStatusScheduler {
         return value == null ? "" : value.replaceAll("\\s+", "").trim().toLowerCase();
     }
 
-    private String normalizeBankName(String value) {
-        return normalizeText(value)
-                .replace("股份有限公司", "")
-                .replace("有限责任公司", "");
-    }
-
     private List<Map<String, Object>> extractTransactionRecords(Map<String, Object> response) {
         if (response == null) {
             return Collections.emptyList();
@@ -268,6 +259,12 @@ public class BankingPaymentStatusScheduler {
             LOGGER.info("Banking electronic bill downloaded businessTransNo={} fileName={}", businessTransNo,
                     receipt.getFileName());
         } catch (RuntimeException exception) {
+            if (isYidaFormDataDeleted(exception)) {
+                trackingRepository.markYidaFormDataDeleted(businessTransNo);
+                LOGGER.warn("Banking receipt processing skipped because Yida form data was deleted businessTransNo={}",
+                        businessTransNo);
+                return;
+            }
             trackingRepository.markReceiptDownloadFailed(businessTransNo);
             // 回单下载、文件保存和宜搭链接回写任一环节失败时均保留重试状态,日志必须包含原因以便排查。
             LOGGER.warn("Banking payment succeeded but electronic bill is unavailable businessTransNo={}",
@@ -293,6 +290,24 @@ public class BankingPaymentStatusScheduler {
         return value != null && !value.trim().isEmpty();
     }
 
+    private boolean isYidaFormDataDeleted(Throwable exception) {
+        Throwable current = exception;
+        while (current != null) {
+            String message = current.getMessage();
+            if (message != null && message.contains(YIDA_FORM_DATA_DELETED_MESSAGE)) {
+                return true;
+            }
+            current = current.getCause();
+        }
+        return false;
+    }
+
+    private enum YidaWriteBackResult {
+        SUCCEEDED,
+        RETRY_LATER,
+        FORM_DATA_DELETED
+    }
+
     private String buildReceiptUrl(String fileName, String option) {
         return bankingProperties.getPublicBaseUrl().replaceAll("/+$", "")
                 + "/api/banking/electronic-bills/files/"

+ 62 - 14
src/main/java/com/malk/minglei/service/impl/banking/BankingPaymentTrackingRepository.java

@@ -7,6 +7,7 @@ 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 org.springframework.transaction.annotation.Transactional;
 
 import java.util.List;
 
@@ -19,6 +20,7 @@ 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 static final int MAX_PAYMENT_ATTEMPT_NO = 9999;
 
     private final ObjectProvider<JdbcTemplate> jdbcTemplateProvider;
     private final BankingPaymentTrackingProperties properties;
@@ -38,26 +40,46 @@ public class BankingPaymentTrackingRepository {
     }
 
     /**
-     * 制单成功后写入待补偿记录;同一支付单号重复提交时保留原有处理状态。
+     * 为同一原单据号分配下一次银企支付流水号并预先保存跟踪记录。
+     * 数据库锁和唯一索引共同保证多个应用实例并发提交时不会获得相同的流水号。
+     *
+     * @param request 包含原始单据号和支付信息的制单请求
+     * @return 发送至银企接口的唯一支付流水号
      */
-    public void saveSubmittedPayment(SinglePaymentRequest request) {
+    @Transactional
+    public String reservePaymentAttempt(SinglePaymentRequest request) {
+        String sourceBusinessTransNo = requireText(request.getBusinessTransNo(), "原单据号不能为空");
+        if (sourceBusinessTransNo.length() > 124) {
+            throw new IllegalArgumentException("原单据号长度不能超过 124 个字符");
+        }
+        Integer latestAttemptNo = findLatestAttemptNoForUpdate(sourceBusinessTransNo);
+        int paymentAttemptNo = latestAttemptNo == null ? 1 : latestAttemptNo.intValue() + 1;
+        if (paymentAttemptNo > MAX_PAYMENT_ATTEMPT_NO) {
+            throw new IllegalStateException("同一单据号的支付提交次数不能超过 9999 次");
+        }
+        String businessTransNo = sourceBusinessTransNo + String.format("%04d", Integer.valueOf(paymentAttemptNo));
         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(),
+                + "(source_business_trans_no, payment_attempt_no, business_trans_no, pay_amount, payment_account_no, "
+                + "customerAccountNo, customerName, customerBank, remark, payment_status, tracking_status, form_id, "
+                + "form_instance_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)";
+        jdbcTemplate().update(sql, sourceBusinessTransNo, Integer.valueOf(paymentAttemptNo), businessTransNo,
+                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()));
+        return businessTransNo;
+    }
+
+    /**
+     * 标记银企接口已明确拒绝制单的记录,避免定时任务继续查询不存在的支付单。
+     *
+     * @param businessTransNo 已分配的银企支付流水号
+     */
+    public void markSubmissionRejected(String businessTransNo) {
+        String sql = "UPDATE " + tableName() + " SET payment_status = 'SUBMISSION_REJECTED', tracking_status = ?, "
+                + "updated_at = CURRENT_TIMESTAMP WHERE business_trans_no = ?";
+        jdbcTemplate().update(sql, TRACKING_STATUS_COMPLETED, businessTransNo);
     }
 
     /**
@@ -125,6 +147,18 @@ public class BankingPaymentTrackingRepository {
         jdbcTemplate().update(sql, emptyToNull(paymentStatus), TRACKING_STATUS_COMPLETED, businessTransNo);
     }
 
+    /**
+     * 宜搭表单实例已删除时结束补偿任务,避免持续查询支付状态、下载回单和回写不存在的数据。
+     * 保留已记录的银企支付状态,供后续人工核查。
+     *
+     * @param businessTransNo 银企支付流水号
+     */
+    public void markYidaFormDataDeleted(String businessTransNo) {
+        String sql = "UPDATE " + tableName() + " SET tracking_status = ?, updated_at = CURRENT_TIMESTAMP "
+                + "WHERE business_trans_no = ?";
+        jdbcTemplate().update(sql, TRACKING_STATUS_COMPLETED, businessTransNo);
+    }
+
     /**
      * 查询异常时更新时间,记录会在下一轮任务继续重试。
      */
@@ -164,4 +198,18 @@ public class BankingPaymentTrackingRepository {
         return request.getPurpose();
     }
 
+    private Integer findLatestAttemptNoForUpdate(String sourceBusinessTransNo) {
+        String sql = "SELECT payment_attempt_no FROM " + tableName()
+                + " WHERE source_business_trans_no = ? ORDER BY payment_attempt_no DESC LIMIT 1 FOR UPDATE";
+        return jdbcTemplate().query(sql, preparedStatement -> preparedStatement.setString(1, sourceBusinessTransNo),
+                resultSet -> resultSet.next() ? Integer.valueOf(resultSet.getInt("payment_attempt_no")) : null);
+    }
+
+    private String requireText(String value, String message) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalArgumentException(message);
+        }
+        return value.trim();
+    }
+
 }

+ 2 - 1
src/main/java/com/malk/minglei/service/impl/banking/BankingPaymentYidaWriteBackService.java

@@ -27,6 +27,7 @@ public class BankingPaymentYidaWriteBackService {
     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 ADDITIONAL_PAYMENT_FORM_UUID = "FORM-FFB779628D49405F9130FAF7CE46491E11Q9";
     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);
@@ -90,7 +91,7 @@ public class BankingPaymentYidaWriteBackService {
      * 根据宜搭表单 UUID 选择字段映射,避免不同表单的同义字段互相覆盖。
      */
     private PaymentFieldMapping resolveFieldMapping(String formID) {
-        if (MATERIAL_PAYMENT_FORM_UUID.equals(formID)) {
+        if (MATERIAL_PAYMENT_FORM_UUID.equals(formID) || ADDITIONAL_PAYMENT_FORM_UUID.equals(formID)) {
             return MATERIAL_PAYMENT_FIELDS;
         }
         if (EXPENSE_REIMBURSEMENT_FORM_UUID.equals(formID)) {

+ 31 - 10
src/main/java/com/malk/minglei/service/impl/banking/BankingServiceImpl.java

@@ -168,12 +168,20 @@ public class BankingServiceImpl implements BankingService {
         require(properties.getIntraBankName(), "MINGLEI_BANKING_INTRA_BANK_NAME 未配置");
 
         populateRecipientBankNo(request);
+        String sourceBusinessTransNo = request.getBusinessTransNo();
+        String businessTransNo = reservePaymentBusinessTransNo(request);
+        request.setBusinessTransNo(businessTransNo);
         Map<String, Object> payment = createPaymentPayload(request);
-        LOGGER.info("Submitting banking payment businessTransNo={}", request.getBusinessTransNo());
+        LOGGER.info("Submitting banking payment sourceBusinessTransNo={} businessTransNo={}", sourceBusinessTransNo,
+                businessTransNo);
         Map<String, Object> response = properties.isPaymentEncryptionEnabled()
                 ? postEncryptedPayment(payment) : post(PAYMENT_PATH, payment);
-        saveSubmittedPaymentForTracking(request, response);
-        return response;
+        if (!isSuccessfulBankResponse(response)) {
+            markPaymentTrackingSubmissionRejected(businessTransNo);
+        }
+        Map<String, Object> result = new LinkedHashMap<String, Object>(response);
+        result.put("businessTransNo", businessTransNo);
+        return result;
     }
 
     /**
@@ -346,17 +354,30 @@ public class BankingServiceImpl implements BankingService {
     }
 
     /**
-     * 银企制单已成功后保存待查询记录。记录库不可用不会将已成功的制单响应误报为失败,避免调用方重复制单。
+     * 在调用银企制单前,为原单据号分配本次支付的唯一流水号。
+     *
+     * <p>流水号分配依赖数据库事务;生产运行时若跟踪库不可用,应中止本次制单,避免产生无法追踪的重复支付。</p>
+     *
+     * @param request 外部传入的支付请求,包含原单据号
+     * @return 拼接四位递增尝试序号后的银企支付流水号
      */
-    private void saveSubmittedPaymentForTracking(SinglePaymentRequest request, Map<String, Object> response) {
-        if (!isSuccessfulBankResponse(response) || paymentTrackingRepository == null) {
+    private String reservePaymentBusinessTransNo(SinglePaymentRequest request) {
+        if (paymentTrackingRepository == null) {
+            // 仅用于没有注入跟踪仓储的历史单元测试构造方式;Spring 运行时必须使用数据库分配流水号。
+            return request.getBusinessTransNo();
+        }
+        return paymentTrackingRepository.reservePaymentAttempt(request);
+    }
+
+    private void markPaymentTrackingSubmissionRejected(String businessTransNo) {
+        if (paymentTrackingRepository == null) {
             return;
         }
         try {
-            paymentTrackingRepository.saveSubmittedPayment(request);
-        } catch (Exception exception) {
-            LOGGER.error("Banking payment tracking record was not saved businessTransNo={}", request.getBusinessTransNo(),
-                    exception);
+            paymentTrackingRepository.markSubmissionRejected(businessTransNo);
+        } catch (RuntimeException exception) {
+            LOGGER.error("Banking rejected payment but tracking state was not updated businessTransNo={}",
+                    businessTransNo, exception);
         }
     }
 

+ 29 - 3
src/test/java/com/malk/minglei/service/impl/banking/BankingPaymentStatusSchedulerTest.java

@@ -19,6 +19,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -85,19 +86,19 @@ class BankingPaymentStatusSchedulerTest {
     }
 
     @Test
-    void matchesBankBranchWhenTransactionUsesTheBankFullName() {
+    void matchesTransactionWhenBankBranchDoesNotMatch() {
         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-BCM-1", BankingPaymentTrackingRepository.TRACKING_STATUS_PENDING);
-        payment.setCustomerBank("嘉兴银行海盐支行");
+        payment.setCustomerBank("交通银行宁波姜山支行");
         given(repository.isAvailable()).willReturn(true);
         given(repository.findOutstandingPayments()).willReturn(Collections.singletonList(payment));
         given(bankingService.queryPaymentStatus("PAY-BCM-1")).willReturn(paymentStatusResponse("X0000"));
         given(bankingService.queryTransactions(any(BankTransactionQueryRequest.class)))
-                .willReturn(transactionResponse("UNIQUE-BCM-1", "memo", "嘉兴银行股份有限公司海盐支行"));
+                .willReturn(transactionResponse("UNIQUE-BCM-1", "memo", "交通银行南通海安支行"));
         given(bankingService.downloadElectronicBill(any(ElectronicBillDownloadRequest.class)))
                 .willReturn(new ElectronicBillDownloadResult("PAY-BCM-1.pdf", "/minglei/pay_file/PAY-BCM-1.pdf",
                         1024L, "application/pdf"));
@@ -151,6 +152,31 @@ class BankingPaymentStatusSchedulerTest {
         verify(bankingService, never()).downloadElectronicBill(any(ElectronicBillDownloadRequest.class));
     }
 
+    @Test
+    void stopsProcessingWhenYidaFormDataWasDeleted() {
+        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-DELETED", BankingPaymentTrackingRepository.TRACKING_STATUS_PENDING);
+        payment.setFormID("FORM-1");
+        payment.setFormInstanceId("INSTANCE-DELETED");
+        given(repository.isAvailable()).willReturn(true);
+        given(repository.findOutstandingPayments()).willReturn(Collections.singletonList(payment));
+        given(bankingService.queryPaymentStatus("PAY-DELETED")).willReturn(paymentStatusResponse("X0000"));
+        doThrow(new IllegalStateException("宜搭数据不存在或已删除"))
+                .when(yidaWriteBackService).writePaymentResult("FORM-1", "INSTANCE-DELETED", "X0000", null);
+
+        scheduler.queryOutstandingPaymentStatuses();
+
+        verify(repository).recordPaymentStatus("PAY-DELETED", "X0000");
+        verify(repository).markYidaFormDataDeleted("PAY-DELETED");
+        verify(bankingService, never()).queryTransactions(any(BankTransactionQueryRequest.class));
+        verify(bankingService, never()).downloadElectronicBill(any(ElectronicBillDownloadRequest.class));
+        verify(repository, never()).markReceiptDownloadFailed("PAY-DELETED");
+    }
+
     private PendingBankingPayment payment(String businessTransNo, String trackingStatus) {
         PendingBankingPayment payment = new PendingBankingPayment();
         payment.setBusinessTransNo(businessTransNo);

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

@@ -78,6 +78,29 @@ class BankingPaymentYidaWriteBackServiceTest {
         assertEquals(1, ((List<?>) updateData.get("attachmentField_mvrimmdz")).size());
     }
 
+    @Test
+    void usesAdditionalPaymentFormFieldMapping() 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-FFB779628D49405F9130FAF7CE46491E11Q9", "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);

+ 38 - 2
src/test/java/com/malk/minglei/service/impl/banking/BankingServiceImplTest.java

@@ -372,10 +372,46 @@ class BankingServiceImplTest {
         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(trackingRepository.reservePaymentAttempt(request)).willReturn("PAY-TRACKING0001");
 
-        service.submitSinglePayment(request);
+        Map<String, Object> result = service.submitSinglePayment(request);
+
+        assertEquals("PAY-TRACKING0001", result.get("businessTransNo"));
+        verify(trackingRepository).reservePaymentAttempt(request);
+    }
+
+    @Test
+    void queriesPaymentStatusUsingAllocatedBusinessTransactionNumber() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        BankingProperties properties = properties();
+        properties.setPaymentEncryptionEnabled(false);
+        BankingPaymentTrackingRepository trackingRepository = mock(BankingPaymentTrackingRepository.class);
+        BankingServiceImpl service = new BankingServiceImpl(restTemplate, properties, new ObjectMapper(), trackingRepository);
+        stubBankBranchSearch(restTemplate);
+        SinglePaymentRequest request = new SinglePaymentRequest();
+        request.setBusinessTransNo("ML202611030001");
+        request.setPayAmount(new BigDecimal("1.00"));
+        request.setFromAccountNo("from-account");
+        request.setFromAccountName("from-name");
+        request.setToAccountNo("to-account");
+        request.setToBankName("交通银行");
+        String allocatedBusinessTransNo = "ML2026110300010002";
+        URI statusUri = URI.create("https://bank.example/api/v2/payment/transferStatus?businessTransNo="
+                + allocatedBusinessTransNo);
+        given(trackingRepository.reservePaymentAttempt(request)).willReturn(allocatedBusinessTransNo);
+        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));
 
-        verify(trackingRepository).saveSubmittedPayment(eq(request));
+        Map<String, Object> result = service.submitSinglePaymentAndQueryStatus(request);
+
+        Map<String, Object> submitResponse = (Map<String, Object>) result.get("submitResponse");
+        assertEquals(allocatedBusinessTransNo, submitResponse.get("businessTransNo"));
+        verify(restTemplate).exchange(eq(statusUri), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class));
+        verify(trackingRepository).reservePaymentAttempt(request);
     }
 
     /**