feat: 付款记录回写金蝶电子回单文件名并支持凭证A801-2下载

This commit is contained in:
刘泉佳
2026-11-06 17:39:26 +08:00
parent 9f1b295c2f
commit f12cff1789
6 changed files with 174 additions and 0 deletions
@@ -24,6 +24,7 @@ import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import jakarta.servlet.http.HttpServletResponse;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
@@ -147,4 +148,13 @@ public class PaymentApplicationController extends BladeController {
public R<String> syncKingdeeResult(@RequestBody(required = false) PaymentApplicationSyncResultRequest request) {
return R.data(paymentApplicationService.syncKingdeePaymentResult(request == null ? null : request.getPaymentNos()));
}
@GetMapping("/record/download-voucher")
@ApiOperationSupport(order = 13)
@Operation(summary = "付款记录付款凭证下载(A801-2 流式回源)",
description = "按付款记录落库的凭证文件名(A103-4 bankcheckentity.awrp_receiptno.uploadfilename)回源金蝶文件流;"
+ "成功返回文件流,失败返回 JSON 错误")
public void downloadRecordVoucher(@RequestParam Long recordId, HttpServletResponse response) {
paymentApplicationService.downloadRecordVoucher(recordId, response);
}
}
@@ -20,6 +20,7 @@
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import jakarta.servlet.http.HttpServletResponse;
import org.springblade.core.mp.base.BaseService;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO;
@@ -125,6 +126,14 @@ public interface IPaymentApplicationService extends BaseService<PaymentApplicati
*/
void applyPayBillRecord(PaymentApplication entity, BfiPayBillVO payBill);
/**
* 付款记录付款凭证下载(A801-2 流式回源):按付款记录落库的凭证文件名回源金蝶文件流
*
* @param recordId 付款记录id
* @param response HTTP 响应(成功写文件流,失败写 JSON 错误)
*/
void downloadRecordVoucher(Long recordId, HttpServletResponse response);
/**
* 付款方式是否为汇票付款(按业务字典 pay_method 条目文本匹配"汇票")
*
@@ -24,13 +24,17 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import feign.Response;
import jakarta.servlet.http.HttpServletResponse;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.core.tool.utils.WebUtil;
import org.springblade.system.cache.DictBizCache;
import org.springblade.system.pojo.entity.DictBiz;
import org.springblade.thirdparty.bfi.constant.BfiConstant;
@@ -44,6 +48,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyEntryVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillReceiptVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
@@ -118,6 +123,9 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URLEncoder;
@@ -126,6 +134,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -150,6 +159,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private static final int PAY_RESULT_PAGE_SIZE = 200;
/** 历史模拟付款记录单号前缀 */
private static final String MOCK_PAYMENT_RECORD_PREFIX = "MOCK-PAY-";
/** A801-2 凭证文件流式拷贝缓冲区大小 */
private static final int VOUCHER_DOWNLOAD_BUFFER_SIZE = 8 * 1024;
/** 金蝶付款结果回写 single-flight 锁键前缀(多副本定时任务与手动触发共用) */
private static final String PAY_RESULT_SYNC_LOCK_PREFIX = "transport:payment-application:kingdee-pay-result-sync:";
private final PaymentApplicationInvoiceMapper invoiceMapper;
@@ -1098,6 +1109,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
record.setKingdeeBillNo(billNo);
// 金蝶付款处理单内码(cas_paybill.id)落库:财务应付单 preentry 预付抵扣(y_billid)取数用
record.setKingdeeBillId(payBill.getId() == null ? null : String.valueOf(payBill.getId()));
// 付款凭证:isgetreceipt=true 表示金蝶已生成电子回单,落库回单文件名(A801-2 文件下载键)
record.setVoucherFileName(this.resolveReceiptFileKey(payBill));
// 付款处理单查询结果整体留档(凭证号、银行流水、电子回单等信息),便于追溯
record.setVoucherJson(JsonUtil.toJson(payBill));
if (isNew) {
@@ -1107,6 +1120,115 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
}
/**
* 提取电子回单文件名(A801-2 文件下载键):isgetreceipt=true 且 bankcheckentity.awrp_receiptno
* 存在时取第一个非空 uploadfilename;否则返回 null(回写置空,保持重复回写的幂等)
*
* @param payBill A103-4 查询返回的付款处理单
* @return 电子回单文件名,无回单返回 null
*/
private String resolveReceiptFileKey(BfiPayBillVO payBill) {
if (!Boolean.TRUE.equals(payBill.getIsgetreceipt()) || Func.isEmpty(payBill.getBankcheckentity())) {
return null;
}
return payBill.getBankcheckentity().stream()
.filter(bankCheck -> Func.isNotEmpty(bankCheck.getAwrpReceiptno()))
.flatMap(bankCheck -> bankCheck.getAwrpReceiptno().stream())
.map(BfiPayBillReceiptVO::getUploadfilename)
.filter(Func::isNotBlank)
.findFirst().orElse(null);
}
@Override
public void downloadRecordVoucher(Long recordId, HttpServletResponse response) {
PaymentApplicationRecord record = recordMapper.selectById(recordId);
String fileKey = record == null ? null : record.getVoucherFileName();
if (Func.isBlank(fileKey)) {
WebUtil.renderJson(response, R.fail("该付款记录没有付款凭证"));
return;
}
long start = System.currentTimeMillis();
try {
Response bfiResponse = bfiService.downloadInvoice(fileKey);
try {
String contentType = this.firstHeader(bfiResponse, "Content-Type");
// A801-2 失败以 JSON 体返回(如 {"status":false,"errorCode":"FILE_IS_NOT_EXIST"}),先校验再写流:
// 写流前的失败统一以 JSON 返回,前端按响应 Content-Type 识别提示,浏览器不落损坏文件
if (contentType != null && contentType.contains("application/json")) {
throw new ServiceException("调用BFI文件下载失败:" + this.readBfiJsonMessage(bfiResponse));
}
// 电子回单为 PDF 版式,uploadfilename 无扩展名,按付款单号命名
String fileName = record.getPaymentNo() + "-付款凭证.pdf";
// BFI 文件流统一回 octet-stream:PDF 版式须显式声明 MIME,否则浏览器 blob 不渲染
if (Func.isBlank(contentType) || contentType.contains("octet-stream")) {
contentType = "application/pdf";
}
// 文件名按 RFC 5987 编码(+ 号转 %20),中文不乱码
String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType(contentType);
String contentLength = this.firstHeader(bfiResponse, "Content-Length");
if (contentLength != null) {
try {
response.setContentLengthLong(Long.parseLong(contentLength));
} catch (NumberFormatException ignored) {
// 非法长度头按无长度处理,走分块传输
}
}
response.setHeader("Content-Disposition",
"attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName);
int totalBytes = 0;
try (InputStream inputStream = bfiResponse.body().asInputStream();
OutputStream outputStream = response.getOutputStream()) {
byte[] buffer = new byte[VOUCHER_DOWNLOAD_BUFFER_SIZE];
int read;
while ((read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
totalBytes += read;
}
outputStream.flush();
}
log.info("付款记录[{}]付款凭证下载完成:文件[{}],{}字节,耗时{}ms",
recordId, fileKey, totalBytes, System.currentTimeMillis() - start);
} finally {
bfiResponse.close();
}
} catch (ServiceException exception) {
log.warn("付款记录[{}]付款凭证下载失败:{}", recordId, exception.getMessage());
WebUtil.renderJson(response, R.fail(exception.getMessage()));
} catch (IOException exception) {
// 传输中断类失败(客户端断开等):响应通道已不可用,仅记录日志
log.error("付款记录[{}]付款凭证下载传输异常", recordId, exception);
}
}
/** 读取 A801-2 JSON 错误体提示信息(优先 message,兜底 errorCode) */
private String readBfiJsonMessage(Response response) {
try (InputStream inputStream = response.body().asInputStream()) {
Object parsed = JsonUtil.parse(new String(inputStream.readAllBytes(), StandardCharsets.UTF_8), Object.class);
if (parsed instanceof Map<?, ?> map) {
for (String key : List.of("message", "errorCode")) {
Object value = map.get(key);
if (value != null && Func.isNotBlank(String.valueOf(value))) {
return String.valueOf(value);
}
}
}
} catch (Exception exception) {
log.warn("解析BFI文件下载错误响应失败", exception);
}
return "响应体为JSON错误";
}
/** 大小写不敏感取第一个响应头值 */
private String firstHeader(Response response, String name) {
for (Map.Entry<String, Collection<String>> entry : response.headers().entrySet()) {
if (name.equalsIgnoreCase(entry.getKey()) && Func.isNotEmpty(entry.getValue())) {
return entry.getValue().iterator().next();
}
}
return null;
}
/**
* A103-2 返回状态映射本地金蝶同步状态:
* paystatus=Alreadypay 已付款(终态)优先;billstatus=D 已关闭(终态)次之;