feat: 金蝶票池发票文件下载(A801-2):按发票号镜像优先/实时回源定位,后端流式代理供前端查看下载
This commit is contained in:
+9
@@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
@@ -86,6 +87,14 @@ public class InvoiceReceiptController extends BladeController {
|
||||
return R.data(invoiceReceiptService.invoicePoolPage(query));
|
||||
}
|
||||
|
||||
@GetMapping("/invoice-pool/attachment/download")
|
||||
@ApiOperationSupport(order = 17)
|
||||
@Operation(summary = "金蝶票池发票文件下载(A801-2 流式回源)",
|
||||
description = "按发票号定位金蝶侧文件:本地镜像优先,镜像未落库实时回源 A801-1;成功返回文件流,失败返回 JSON 错误")
|
||||
public void downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response) {
|
||||
invoiceReceiptService.downloadPoolAttachment(invoiceNo, response);
|
||||
}
|
||||
|
||||
@GetMapping("/settlement-candidates")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "可关联的应付正式结算单")
|
||||
|
||||
+12
@@ -26,6 +26,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.transport.pojo.dto.InvoiceReceiptSaveRequest;
|
||||
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
|
||||
@@ -58,6 +59,17 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
|
||||
*/
|
||||
KingdeeInvoicePoolPageVO invoicePoolPage(KingdeeInvoicePoolQueryDTO query);
|
||||
|
||||
/**
|
||||
* 金蝶票池发票文件下载(A801-2 流式回源,前端查看/下载代理端点)。
|
||||
* <p>按发票号定位金蝶侧文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证),
|
||||
* 镜像未落库或无文件时实时回源 A801-1 取 downloadUrl;经 A801-2 下载后流式透传浏览器,
|
||||
* 不落盘不进堆。写流前的业务失败以 JSON(R.fail)写入响应,前端按 Content-Type 识别提示。
|
||||
*
|
||||
* @param invoiceNo 发票号码
|
||||
* @param response 响应对象(文件流写出口)
|
||||
*/
|
||||
void downloadPoolAttachment(String invoiceNo, HttpServletResponse response);
|
||||
|
||||
List<Map<String, Object>> settlementCandidates(String keyword, Long receiptId);
|
||||
|
||||
Map<String, Object> referenceInformation(String settlementIds);
|
||||
|
||||
+145
@@ -28,14 +28,18 @@ package org.springblade.transport.service.impl;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import feign.Response;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.core.tool.utils.WebUtil;
|
||||
import org.springblade.thirdparty.bfi.constant.BfiConstant;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO;
|
||||
@@ -82,12 +86,18 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
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;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -112,6 +122,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
/** A801-1 rim 契约单页最大条数 */
|
||||
private static final int POOL_PAGE_SIZE = 500;
|
||||
|
||||
/** A801-2 文件下载流式拷贝缓冲区大小 */
|
||||
private static final int DOWNLOAD_BUFFER_SIZE = 8 * 1024;
|
||||
|
||||
private final KingdeeInvoicePoolMapper invoicePoolMapper;
|
||||
private final InvoiceReceiptSettlementMapper settlementRelationMapper;
|
||||
private final InvoiceReceiptRecordMapper recordMapper;
|
||||
@@ -1201,6 +1214,138 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
return JsonUtil.toJson(attachment);
|
||||
}
|
||||
|
||||
// ==================== 金蝶票池发票文件下载(A801-2 前端代理,见 2026-09-27 设计文档) ====================
|
||||
|
||||
@Override
|
||||
public void downloadPoolAttachment(String invoiceNo, HttpServletResponse response) {
|
||||
String trimmedInvoiceNo = trimOrNull(invoiceNo);
|
||||
if (StringUtil.isBlank(trimmedInvoiceNo)) {
|
||||
WebUtil.renderJson(response, R.fail("发票号不能为空"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
doDownloadPoolAttachment(trimmedInvoiceNo, response);
|
||||
} catch (ServiceException exception) {
|
||||
// 写流前的业务失败统一以 JSON 返回:前端按响应 Content-Type 识别提示,浏览器不落损坏文件
|
||||
log.warn("金蝶票池发票文件下载失败:发票[{}]:{}", trimmedInvoiceNo, exception.getMessage());
|
||||
WebUtil.renderJson(response, R.fail(exception.getMessage()));
|
||||
} catch (IOException exception) {
|
||||
// 传输中断类失败(客户端断开等):响应通道已不可用,仅记录日志
|
||||
log.error("金蝶票池发票文件下载传输异常:发票[{}]", trimmedInvoiceNo, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载主流程:按发票号定位金蝶侧文件 path(镜像优先、A801-1 实时回源兜底)→ A801-2 文件流
|
||||
* → 先校验后写头(失败在前端可识别阶段暴露)→ 8KB 流式透传,不落盘不进堆
|
||||
*/
|
||||
private void doDownloadPoolAttachment(String invoiceNo, HttpServletResponse response) throws IOException {
|
||||
KingdeeAttachmentSyncSupport.KingdeeAttachmentItem item = resolvePoolAttachment(invoiceNo);
|
||||
if (item == null || Func.isBlank(item.key())) {
|
||||
throw new ServiceException("该发票没有金蝶侧文件");
|
||||
}
|
||||
long start = System.currentTimeMillis();
|
||||
Response bfiResponse = bfiService.downloadInvoice(item.key());
|
||||
try {
|
||||
String contentType = firstHeader(bfiResponse, "Content-Type");
|
||||
// A801-2 失败以 JSON 体返回(如 {"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}),先校验再写流
|
||||
if (contentType != null && contentType.contains("application/json")) {
|
||||
throw new ServiceException("调用BFI文件下载失败:" + readBfiJsonMessage(bfiResponse));
|
||||
}
|
||||
String fileName = Func.isBlank(item.name()) ? invoiceNo + ".pdf" : item.name();
|
||||
// 文件名按 RFC 5987 编码(+ 号转 %20),中文/特殊字符不乱码
|
||||
String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
response.setContentType(Func.isBlank(contentType) ? "application/pdf" : contentType);
|
||||
String contentLength = 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[DOWNLOAD_BUFFER_SIZE];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
totalBytes += read;
|
||||
}
|
||||
outputStream.flush();
|
||||
}
|
||||
log.info("金蝶票池发票文件下载完成:发票[{}],文件[{}],{}字节,耗时{}ms",
|
||||
invoiceNo, fileName, totalBytes, System.currentTimeMillis() - start);
|
||||
} finally {
|
||||
bfiResponse.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位金蝶侧发票文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证),
|
||||
* 镜像未落库(新进票池尚未同步)或镜像无文件时实时回源 A801-1 按票号取 downloadUrl;
|
||||
* 远程票号查询为模糊匹配,命中行需与目标票号全等防御
|
||||
*/
|
||||
private KingdeeAttachmentSyncSupport.KingdeeAttachmentItem resolvePoolAttachment(String invoiceNo) {
|
||||
List<KingdeeInvoicePool> mirrors = invoicePoolMapper.selectList(Wrappers.<KingdeeInvoicePool>lambdaQuery()
|
||||
.eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo));
|
||||
if (Func.isNotEmpty(mirrors)) {
|
||||
Optional<KingdeeAttachmentSyncSupport.KingdeeAttachmentItem> mirrorItem = kingdeeAttachmentSyncSupport
|
||||
.parseInvoicePoolFile(mirrors.get(0).getAttachmentsJson()).stream().findFirst();
|
||||
if (mirrorItem.isPresent()) {
|
||||
return mirrorItem.get();
|
||||
}
|
||||
}
|
||||
KingdeeInvoicePoolQueryDTO query = new KingdeeInvoicePoolQueryDTO();
|
||||
query.setInvoiceNo(invoiceNo);
|
||||
List<BfiInvoicePoolVO> rows = queryPoolPage(1, POOL_PAGE_SIZE, query);
|
||||
for (BfiInvoicePoolVO row : rows) {
|
||||
if (row.getInvoiceNo() != null && invoiceNo.equals(row.getInvoiceNo().trim())) {
|
||||
return kingdeeAttachmentSyncSupport.parseInvoicePoolFile(buildAttachmentJson(row))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 读取 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) {
|
||||
String message = firstTextValue(map, "message");
|
||||
if (Func.isNotBlank(message)) {
|
||||
return message;
|
||||
}
|
||||
String errorCode = firstTextValue(map, "errorCode");
|
||||
if (Func.isNotBlank(errorCode)) {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
private String firstTextValue(Map<?, ?> map, String key) {
|
||||
Object value = map.get(key);
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 税率优先取顶层小数换算为百分数(0.13 -> 13),顶层为空回退首条明细,均无效时置 0(镜像列非空约束)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user