✨ 收票管理对接金蝶全票池(A801-1):批量同步/定时绑定/补偿兜底

批量同步按钮改调 A801-1 按发票号码精确查询并回填绑定;新增定时绑定任务(滚动增量窗口分页、镜像批量 upsert、绑定页级事务批量落库、发票种类与客商税号白名单过滤、未绑定登记按号补偿兜底);新增手动触发端点 /invoice-receipt/sync-invoice-pool
This commit is contained in:
刘泉佳
2026-09-21 13:18:52 +08:00
parent 69fee13242
commit f13f983467
4 changed files with 700 additions and 11 deletions
@@ -154,4 +154,11 @@ public class InvoiceReceiptController extends BladeController {
public R<String> syncKingdee(@RequestParam Long id) {
return R.data(invoiceReceiptService.syncKingdee(id));
}
@PostMapping("/sync-invoice-pool")
@ApiOperationSupport(order = 13)
@Operation(summary = "手动触发金蝶全票池发票同步绑定")
public R<String> syncInvoicePool() {
return R.data(invoiceReceiptService.syncKingdeeInvoicePool());
}
}
@@ -30,6 +30,7 @@ import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
import org.springblade.transport.pojo.entity.InvoiceReceiptRecord;
import org.springblade.transport.pojo.entity.KingdeeInvoicePool;
import org.springblade.transport.pojo.vo.InvoiceReceiptVO;
@@ -66,4 +67,31 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
void voidBill(InvoiceReceiptStatusRequest request);
String syncKingdee(Long id);
/**
* 定时查询金蝶全票池发票(A801-1)并绑定收票登记
*
* @return 执行摘要,含拉取页数、镜像新增/更新、绑定与状态刷新统计
*/
String syncKingdeeInvoicePool();
/**
* 批量应用金蝶票池绑定结果(页级事务):登记绑定批量更新 + 同步流水批量写入,整页原子生效
*
* @param receipts 已回填绑定字段的收票登记
* @param records 同步流水
*/
void applyInvoicePoolSyncBatch(List<InvoiceReceipt> receipts, List<InvoiceReceiptRecord> records);
/**
* 应用金蝶票池同步结果:回填收票登记绑定关系与同步状态(独立事务,仅手动单条同步使用)
*
* @param receipt 收票登记实体
* @param invoice upsert 后的票据池镜像记录,null 表示金蝶票池未查询到
* @param missReason 未命中原因,命中时可为空
* @param rebind 是否强制重新绑定镜像(手动同步 true)
* @return 金蝶流水号(serialNo),未命中返回 null
*/
String applyInvoicePoolSyncResult(InvoiceReceipt receipt, KingdeeInvoicePool invoice, String missReason,
boolean rebind);
}
@@ -28,11 +28,24 @@ 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 com.baomidou.mybatisplus.extension.toolkit.Db;
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.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.thirdparty.bfi.constant.BfiConstant;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.mapper.CustomerArchiveMapper;
import org.springblade.transport.mapper.CustomerContactMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
@@ -52,15 +65,23 @@ import org.springblade.transport.pojo.entity.KingdeeInvoicePool;
import org.springblade.transport.pojo.vo.InvoiceReceiptVO;
import org.springblade.transport.service.IInvoiceReceiptService;
import org.springblade.transport.wrapper.InvoiceReceiptWrapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -70,23 +91,55 @@ import java.util.stream.Collectors;
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMapper, InvoiceReceipt>
implements IInvoiceReceiptService {
/** 审批状态:草稿(允许编辑、删除) */
private static final String DRAFT = "draft";
/** 审批状态:审批中(收票审核节点,允许通过/驳回) */
private static final String REVIEWING = "reviewing";
/** 审批状态:审批通过(允许同步金蝶、作废) */
private static final String APPROVED = "approved";
/** 审批状态:已驳回(允许再编辑后重新提交) */
private static final String RETURNED = "returned";
/** 审批状态:已作废(终态,不参与绑定与收票统计) */
private static final String VOIDED = "voided";
/** A801-1 rim 契约单页最大条数 */
private static final int POOL_PAGE_SIZE = 500;
/** A801-1 定时拉取最大页数保护,防御翻页死循环 */
private static final int MAX_POOL_PAGES = 50;
/** A801-1 增量窗口更新时间格式 */
private static final DateTimeFormatter POOL_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final KingdeeInvoicePoolMapper invoicePoolMapper;
private final InvoiceReceiptSettlementMapper settlementRelationMapper;
private final InvoiceReceiptRecordMapper recordMapper;
private final FormalSettlementMapper formalSettlementMapper;
private final CustomerArchiveMapper customerArchiveMapper;
private final CustomerContactMapper customerContactMapper;
private final IBfiService bfiService;
/** A801-1 增量窗口天数(按票池更新时间),首轮/兜底全量比对窗口 */
@Value("${thirdParty.bfi.invoiceReceipt.incrementDays:30}")
private int incrementDays;
/** A801-1 同步发票种类白名单(逗号分隔票易通编码,如 26,27),为空不限制 */
@Value("${thirdParty.bfi.invoiceReceipt.invoiceTypes:}")
private String invoiceTypes;
/** 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) */
@Value("${thirdParty.bfi.invoiceReceipt.compensateLimit:200}")
private int compensateLimit;
@Override
public IPage<InvoiceReceiptVO> selectPage(IPage<InvoiceReceipt> page, InvoiceReceiptVO query) {
@@ -329,22 +382,548 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
@Override
@Transactional(rollbackFor = Exception.class)
public String syncKingdee(Long id) {
InvoiceReceipt entity = existing(id);
if (!APPROVED.equals(entity.getApprovalStatus())) {
throw new ServiceException("仅审批通过的收票登记允许同步金蝶状态");
}
KingdeeInvoicePool invoice = invoicePoolMapper.selectById(entity.getKingdeeInvoicePoolId());
if (invoice == null || Objects.equals(invoice.getIsDeleted(), 1)) {
throw new ServiceException("金蝶票据池发票不存在");
// A801-1 按发票号码精确查询(rim 契约单页,命中 0/1 条)
List<BfiInvoicePoolVO> poolInvoices = queryPoolByInvoiceNo(entity.getInvoiceNo());
if (poolInvoices.size() > 1) {
log.warn("收票登记[{}]同步金蝶票池命中 {} 条,取第一条", entity.getInvoiceNo(), poolInvoices.size());
}
entity.setKingdeeBillNo(invoice.getKingdeeBillNo());
entity.setKingdeeStatus(normalizeKingdeeStatus(invoice.getKingdeeStatus()));
updateById(entity);
record(entity.getId(), "sync", "同步金蝶状态", entity.getApprovalStatus(),
entity.getApprovalStatus(), null, entity.getKingdeeBillNo());
return entity.getKingdeeBillNo();
String missReason = null;
KingdeeInvoicePool mirror = null;
if (poolInvoices.isEmpty()) {
missReason = "金蝶票池未查询到发票";
} else {
// 发票种类白名单过滤:白名单外发票不落镜像不绑定
poolInvoices = filterByInvoiceTypes(poolInvoices);
if (poolInvoices.isEmpty()) {
missReason = "发票类型不在同步范围";
} else {
mirror = upsertInvoicePool(mirrorByInvoiceNo(poolInvoices.get(0).getInvoiceNo().trim()),
poolInvoices.get(0));
}
}
// 经自代理独立事务:绑定回填 + 同步流水
return self().applyInvoicePoolSyncResult(entity, mirror, missReason, true);
}
@Override
public String syncKingdeeInvoicePool() {
// ① A801-1 增量窗口分页拉取(票池更新时间窗口,rim 契约不返回总数,按"本页不满页即末页"终止);
// 配置我方购方税号后按税号逐个分页拉取(buyerTaxNo 单值参数),只拉我方收票,分页精确不漏
LocalDateTime windowStart = LocalDateTime.now().minusDays(incrementDays);
LocalDateTime windowEnd = LocalDateTime.now();
IInvoiceReceiptService self = self();
int pulledCount = 0;
int filteredCount = 0;
int salerFilteredCount = 0;
int created = 0;
int updated = 0;
int bound = 0;
int refreshed = 0;
int failed = 0;
// 客商税号白名单:客商档案有效税号(购、销双方税号均须命中),集合为空不限制
Set<String> customerTaxNos = customerTaxNoSet();
log.info("定时绑定金蝶全票池开始,增量窗口 {} ~ {},发票种类:{},客商税号白名单 {} 个",
windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER),
Func.isEmpty(invoiceTypes) ? "不限制" : invoiceTypes,
customerTaxNos.isEmpty() ? "无(不限制)" : String.valueOf(customerTaxNos.size()));
for (int pageNo = 1; pageNo <= MAX_POOL_PAGES; pageNo++) {
List<BfiInvoicePoolVO> rows = queryPoolPage(windowStart, windowEnd, pageNo);
if (rows.isEmpty()) {
break;
}
pulledCount += rows.size();
// 发票种类白名单 + 客商税号白名单(购、销双方)过滤(翻页终止仍按原始页条数判断)
List<BfiInvoicePoolVO> typeAllowedRows = filterByInvoiceTypes(rows);
filteredCount += rows.size() - typeAllowedRows.size();
List<BfiInvoicePoolVO> allowedRows = filterByArchiveTaxNos(customerTaxNos, typeAllowedRows);
salerFilteredCount += typeAllowedRows.size() - allowedRows.size();
// ② 镜像批量 upsert:票池为唯一事实源,无论是否已登记均刷新镜像(批量查改,避免逐条 N+1)
BatchUpsertResult upsertResult = batchUpsertInvoicePool(allowedRows);
Map<String, KingdeeInvoicePool> mirrorMap = upsertResult.mirrors();
created += upsertResult.created();
updated += upsertResult.updated();
// ③ 同页发票号码批量定位收票登记,内存回填绑定后页级批量落库(批量失败整页跳过待下轮)
if (!mirrorMap.isEmpty()) {
Map<String, List<InvoiceReceipt>> receiptMap = list(Wrappers.<InvoiceReceipt>lambdaQuery()
.in(InvoiceReceipt::getInvoiceNo, mirrorMap.keySet())
.ne(InvoiceReceipt::getApprovalStatus, VOIDED)).stream()
.filter(receipt -> !Objects.equals(receipt.getIsDeleted(), 1))
.collect(Collectors.groupingBy(InvoiceReceipt::getInvoiceNo));
List<BindTarget> targets = new ArrayList<>();
for (Map.Entry<String, KingdeeInvoicePool> entry : mirrorMap.entrySet()) {
for (InvoiceReceipt receipt : receiptMap.getOrDefault(entry.getKey(), List.of())) {
boolean unbound = receipt.getKingdeeInvoicePoolId() == null
|| !Objects.equals(receipt.getKingdeeInvoicePoolId(), entry.getValue().getId());
// 内存回填:绑定关系对齐当前发票号码对应镜像(同租户同发票号镜像唯一),票面字段不覆盖人工登记信息
receipt.setKingdeeInvoicePoolId(entry.getValue().getId());
receipt.setKingdeeBillNo(entry.getValue().getKingdeeBillNo());
receipt.setKingdeeStatus(entry.getValue().getKingdeeStatus());
targets.add(new BindTarget(receipt, entry.getValue(), unbound));
}
}
if (!targets.isEmpty()) {
try {
// 页级事务批量落库:绑定批量更新 + 同步流水批量写入(JDBC batch,整页原子生效)
self.applyInvoicePoolSyncBatch(
targets.stream().map(BindTarget::receipt).toList(),
targets.stream().map(target -> buildSyncRecord(target.receipt(), "定时绑定金蝶票池发票")).toList());
for (BindTarget target : targets) {
if (target.unbound()) {
bound++;
log.info("定时绑定金蝶票池发票成功,发票号码={},登记id={},金蝶流水号={}",
target.receipt().getInvoiceNo(), target.receipt().getId(), target.mirror().getSerialNo());
} else {
refreshed++;
}
}
} catch (Exception batchException) {
// 批量落库失败:整页跳过计入失败,下轮任务幂等重试,不做逐单降级(保持全批量链路)
failed += targets.size();
log.error("定时绑定金蝶票池发票批量落库异常,本页 {} 条跳过待下轮重试", targets.size(), batchException);
}
}
}
if (rows.size() < POOL_PAGE_SIZE) {
break;
}
}
// ④ 补偿绑定:仍为未绑定状态的登记按发票号码精确补查(不受增量窗口限制),
// 兜底增量窗口滑动或批量落库持续失败导致的漏绑——发票在金蝶票池可随时按号补查,直到绑定成功为止
int compensated = compensateUnboundReceipts(self);
String summary = "金蝶全票池同步:拉取" + pulledCount + "张发票,类型过滤" + filteredCount + "张,客商过滤"
+ salerFilteredCount + "张,镜像新增" + created + "更新" + updated + ",绑定" + bound + ",状态刷新"
+ refreshed + ",失败" + failed + ",补偿绑定" + compensated;
log.info("定时绑定金蝶全票池结束,{}", summary);
return summary;
}
/**
* 补偿绑定:未绑定登记(kingdeeInvoicePoolId 为空)按发票号码补查 A801-1 并批量落库,
* 不受增量窗口限制,防漏绑兜底;单轮限量防调用量放大,未查到则跳过待下轮,白名单同样约束。
* 镜像一次批量查询复用(含主流程本轮已落库镜像,命中免远程),缺失才按号精确补查(rim 契约无批量号码参数),
* 新增镜像批量写入、绑定页级事务批量落库
*
* @param self 自代理,批量落库走事务代理
* @return 本轮补偿绑定成功条数
*/
private int compensateUnboundReceipts(IInvoiceReceiptService self) {
List<InvoiceReceipt> unboundReceipts = list(Wrappers.<InvoiceReceipt>lambdaQuery()
.isNull(InvoiceReceipt::getKingdeeInvoicePoolId)
.ne(InvoiceReceipt::getApprovalStatus, VOIDED)
.orderByAsc(InvoiceReceipt::getCreateTime)
.last("limit " + compensateLimit));
if (unboundReceipts.isEmpty()) {
return 0;
}
log.info("定时绑定金蝶全票池补偿开始,未绑定登记 {} 条(限量 {} 条)", unboundReceipts.size(), compensateLimit);
// ① 本地镜像一次批量查询(主流程本轮刚落库的镜像一并命中,免远程补查)
List<String> invoiceNos = unboundReceipts.stream()
.map(InvoiceReceipt::getInvoiceNo).filter(Func::isNotEmpty).map(String::trim).distinct().toList();
Map<String, KingdeeInvoicePool> mirrorMap = invoicePoolMapper.selectList(
Wrappers.<KingdeeInvoicePool>lambdaQuery()
.in(KingdeeInvoicePool::getInvoiceNo, invoiceNos)).stream()
.collect(Collectors.toMap(KingdeeInvoicePool::getInvoiceNo, Function.identity(), (first, second) -> first));
// ② 内存回填绑定;本地镜像缺失的按发票号码精确补查,新增镜像攒批
List<BindTarget> targets = new ArrayList<>();
List<KingdeeInvoicePool> newMirrors = new ArrayList<>();
for (InvoiceReceipt receipt : unboundReceipts) {
if (Func.isEmpty(receipt.getInvoiceNo())) {
continue;
}
String invoiceNo = receipt.getInvoiceNo().trim();
KingdeeInvoicePool mirror = mirrorMap.get(invoiceNo);
if (mirror == null) {
List<BfiInvoicePoolVO> poolInvoices = filterByInvoiceTypes(queryPoolByInvoiceNo(invoiceNo));
if (poolInvoices.isEmpty()) {
// 金蝶票池暂无该发票:跳过不写库,待后续轮次补查,避免流水刷屏
log.warn("补偿绑定跳过,金蝶票池未查询到发票,登记id={},发票号码={}", receipt.getId(), invoiceNo);
continue;
}
mirror = fillInvoicePool(null, poolInvoices.get(0));
newMirrors.add(mirror);
mirrorMap.put(mirror.getInvoiceNo(), mirror);
}
receipt.setKingdeeInvoicePoolId(mirror.getId());
receipt.setKingdeeBillNo(mirror.getKingdeeBillNo());
receipt.setKingdeeStatus(mirror.getKingdeeStatus());
targets.add(new BindTarget(receipt, mirror, true));
}
if (targets.isEmpty()) {
return 0;
}
// ③ 批量落库:新增镜像批量写入 + 绑定页级事务批量落库(失败整轮跳过待下轮重试,绑定幂等)
if (!newMirrors.isEmpty()) {
Db.saveBatch(newMirrors);
log.info("补偿绑定批量写入新增镜像 {} 条", newMirrors.size());
}
self.applyInvoicePoolSyncBatch(
targets.stream().map(BindTarget::receipt).toList(),
targets.stream().map(target -> buildSyncRecord(target.receipt(), "补偿绑定金蝶票池发票")).toList());
targets.forEach(target -> log.info("补偿绑定金蝶票池发票成功,发票号码={},登记id={},金蝶流水号={}",
target.receipt().getInvoiceNo(), target.receipt().getId(), target.mirror().getSerialNo()));
return targets.size();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void applyInvoicePoolSyncBatch(List<InvoiceReceipt> receipts, List<InvoiceReceiptRecord> records) {
if (Func.isEmpty(receipts) && Func.isEmpty(records)) {
return;
}
// 页级事务:绑定批量更新 + 同步流水批量写入,整页原子生效;失败由调用方降级逐单隔离处理
if (Func.isNotEmpty(receipts)) {
updateBatchById(receipts);
}
if (Func.isNotEmpty(records)) {
Db.saveBatch(records);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public String applyInvoicePoolSyncResult(InvoiceReceipt receipt, KingdeeInvoicePool invoice, String missReason,
boolean rebind) {
if (receipt == null) {
return null;
}
if (invoice == null) {
// 未命中票池:登记置未同步并记录原因,等待金蝶侧采集后下轮自动绑定
String fromStatus = receipt.getApprovalStatus();
receipt.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED);
updateById(receipt);
record(receipt.getId(), "sync", "同步金蝶状态", fromStatus, fromStatus, missReason,
receipt.getKingdeeBillNo(), currentOperator());
return null;
}
// 绑定回填:绑定关系对齐当前发票号码对应的镜像(同租户同发票号镜像唯一),票面字段不覆盖人工登记信息
receipt.setKingdeeInvoicePoolId(invoice.getId());
receipt.setKingdeeBillNo(invoice.getKingdeeBillNo());
receipt.setKingdeeStatus(invoice.getKingdeeStatus());
updateById(receipt);
record(receipt.getId(), "sync", "同步金蝶状态", receipt.getApprovalStatus(), receipt.getApprovalStatus(),
rebind ? null : "定时绑定金蝶票池发票", receipt.getKingdeeBillNo(), currentOperator());
return receipt.getKingdeeBillNo();
}
/**
* A801-1 按发票号码精确单查(rim 单页契约,命中 0/1 条)
*/
private List<BfiInvoicePoolVO> queryPoolByInvoiceNo(String invoiceNo) {
BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO();
data.setPageNo(1);
data.setPageSize(POOL_PAGE_SIZE);
data.setInvoiceNo(invoiceNo.trim());
BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO();
param.setQueryParam(data);
BfiPageVO<BfiInvoicePoolVO> page = bfiService.queryInvoicePool(param);
return page.getRows() == null ? List.of() : page.getRows();
}
/**
* A801-1 按票池更新时间窗口分页拉取
*/
private List<BfiInvoicePoolVO> queryPoolPage(LocalDateTime windowStart, LocalDateTime windowEnd, int pageNo) {
BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO();
data.setPageNo(pageNo);
data.setPageSize(POOL_PAGE_SIZE);
data.setUpdateTime(windowStart.format(POOL_TIME_FORMATTER));
data.setUpdateTimeEnd(windowEnd.format(POOL_TIME_FORMATTER));
BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO();
param.setQueryParam(data);
BfiPageVO<BfiInvoicePoolVO> page = bfiService.queryInvoicePool(param);
log.info("定时绑定金蝶全票池 第{}页拉取 {} 条", pageNo, page.getRows() == null ? 0 : page.getRows().size());
return page.getRows() == null ? List.of() : page.getRows();
}
/**
* A801-1 票池发票单条 upsert 镜像(手动同步路径)
*
* @param mirror 既有镜像记录,null 表示新增
* @param row A801-1 票池发票
* @return upsert 后的镜像记录
*/
private KingdeeInvoicePool upsertInvoicePool(KingdeeInvoicePool mirror, BfiInvoicePoolVO row) {
boolean creating = mirror == null;
mirror = fillInvoicePool(mirror, row);
if (creating) {
invoicePoolMapper.insert(mirror);
} else {
invoicePoolMapper.updateById(mirror);
}
return mirror;
}
/**
* 票池镜像批量 upsert:一次查询同页既有镜像,分组后批量插入/更新,避免逐条查改 N+1
*
* @param rows 白名单过滤后的 A801-1 票池发票
* @return 批量 upsert 结果(invoiceNo -&gt; 镜像,新增/更新条数)
*/
private BatchUpsertResult batchUpsertInvoicePool(List<BfiInvoicePoolVO> rows) {
Map<String, KingdeeInvoicePool> mirrorMap = new LinkedHashMap<>();
if (rows.isEmpty()) {
return new BatchUpsertResult(mirrorMap, 0, 0);
}
List<String> invoiceNos = rows.stream()
.map(row -> row.getInvoiceNo().trim())
.distinct()
.toList();
// 同页发票号码一次批量查询既有镜像
Map<String, KingdeeInvoicePool> existingMap = invoicePoolMapper.selectList(
Wrappers.<KingdeeInvoicePool>lambdaQuery()
.in(KingdeeInvoicePool::getInvoiceNo, invoiceNos)).stream()
.collect(Collectors.toMap(KingdeeInvoicePool::getInvoiceNo, Function.identity(), (first, second) -> first));
List<KingdeeInvoicePool> toInsert = new ArrayList<>();
List<KingdeeInvoicePool> toUpdate = new ArrayList<>();
for (BfiInvoicePoolVO row : rows) {
if (Func.isEmpty(row.getInvoiceNo())) {
continue;
}
KingdeeInvoicePool existing = existingMap.get(row.getInvoiceNo().trim());
KingdeeInvoicePool mirror = fillInvoicePool(existing, row);
(existing == null ? toInsert : toUpdate).add(mirror);
mirrorMap.put(mirror.getInvoiceNo(), mirror);
}
// 批量写库:失败时中断本轮,由任务层记录异常、下轮重试(upsert 幂等)
if (!toInsert.isEmpty()) {
Db.saveBatch(toInsert);
}
if (!toUpdate.isEmpty()) {
Db.updateBatchById(toUpdate);
}
log.info("定时绑定金蝶全票池镜像批量写入完成,新增 {} 条,更新 {} 条", toInsert.size(), toUpdate.size());
return new BatchUpsertResult(mirrorMap, toInsert.size(), toUpdate.size());
}
/**
* A801-1 票池发票填充镜像(同租户发票号唯一,票池字段为镜像唯一事实源,不写库)
*
* @param mirror 既有镜像记录,null 表示新增
* @param row A801-1 票池发票
* @return 填充后的镜像记录
*/
private KingdeeInvoicePool fillInvoicePool(KingdeeInvoicePool mirror, BfiInvoicePoolVO row) {
boolean creating = mirror == null;
if (creating) {
mirror = new KingdeeInvoicePool();
mirror.setInvoiceNo(truncate(row.getInvoiceNo(), 32));
mirror.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED);
}
// 金蝶流水号作金蝶侧唯一标识(A801-1 无单据号概念)
mirror.setSerialNo(row.getSerialNo());
mirror.setKingdeeBillNo(row.getSerialNo());
mirror.setKingdeeStatus(poolKingdeeStatus(row));
mirror.setInvoiceDate(parseDate(row.getInvoiceDate()));
mirror.setInvoiceType(BfiConstant.INVOICE_TYPE_NAMES.getOrDefault(row.getInvoiceType(), row.getInvoiceType()));
mirror.setTaxRate(resolveTaxRate(row));
mirror.setInvoiceAmount(parseAmount(row.getTotalAmount()));
mirror.setTaxAmount(parseAmount(row.getTaxAmount()));
mirror.setReceiverName(truncate(row.getBuyerName(), 100));
mirror.setBuyerTaxNo(row.getBuyerTaxNo());
mirror.setIssuerName(truncate(row.getSalerName(), 100));
mirror.setSalerTaxNo(row.getSalerTaxNo());
mirror.setCheckStatus(row.getCheckStatus());
mirror.setAttachmentsJson(buildAttachmentJson(row));
mirror.setSourceUpdatedTime(parseDateTime(row.getBillCreateTime()));
return mirror;
}
/**
* 票池镜像批量 upsert 结果
*
* @param mirrors invoiceNo -&gt; upsert 后镜像
* @param created 新增条数
* @param updated 更新条数
*/
private record BatchUpsertResult(Map<String, KingdeeInvoicePool> mirrors, int created, int updated) {
}
/**
* 定时绑定目标:登记实体 + 对应镜像 + 是否首次绑定(区分绑定与状态刷新统计)
*
* @param receipt 已回填绑定字段的收票登记
* @param mirror 对应票据池镜像
* @param unbound true 表示首次绑定,false 表示仅状态刷新
*/
private record BindTarget(InvoiceReceipt receipt, KingdeeInvoicePool mirror, boolean unbound) {
}
/**
* 构建定时绑定同步流水(批量落库用,操作人以「定时任务」名义记录)
*/
private InvoiceReceiptRecord buildSyncRecord(InvoiceReceipt receipt, String reason) {
InvoiceReceiptRecord syncRecord = new InvoiceReceiptRecord();
syncRecord.setInvoiceReceiptId(receipt.getId());
syncRecord.setActionType("sync");
syncRecord.setActionName("同步金蝶状态");
syncRecord.setFromStatus(receipt.getApprovalStatus());
syncRecord.setToStatus(receipt.getApprovalStatus());
syncRecord.setOperatorName(currentOperator());
syncRecord.setReason(reason);
syncRecord.setKingdeeBillNo(receipt.getKingdeeBillNo());
return syncRecord;
}
/**
* 票池发票同步状态映射:命中即 synced,票池侧作废删除(delete=1)置 failed(不作废登记,人工确认)
*/
private String poolKingdeeStatus(BfiInvoicePoolVO row) {
return "1".equals(row.getDelete()) ? KingdeeBfiConstant.KINGDEE_STATUS_FAILED
: KingdeeBfiConstant.KINGDEE_STATUS_SYNCED;
}
/**
* 发票种类白名单过滤(thirdParty.bfi.invoiceReceipt.invoiceTypes 逗号分隔编码),白名单为空不限制
*/
private List<BfiInvoicePoolVO> filterByInvoiceTypes(List<BfiInvoicePoolVO> rows) {
Set<String> allowed = allowedInvoiceTypes();
if (allowed.isEmpty()) {
return rows;
}
return rows.stream()
.filter(row -> allowed.contains(row.getInvoiceType() == null ? "" : row.getInvoiceType().trim()))
.toList();
}
private Set<String> allowedInvoiceTypes() {
if (StringUtil.isBlank(invoiceTypes)) {
return Set.of();
}
return Arrays.stream(invoiceTypes.split("[,,;;]"))
.map(String::trim)
.filter(StringUtil::isNotBlank)
.collect(Collectors.toSet());
}
/**
* 客商税号白名单过滤:购、销双方税号均须命中客商档案税号集合,集合为空不限制;白名单外发票不入镜像不绑定
*/
private List<BfiInvoicePoolVO> filterByArchiveTaxNos(Set<String> customerTaxNos, List<BfiInvoicePoolVO> rows) {
if (customerTaxNos.isEmpty()) {
return rows;
}
return rows.stream()
.filter(row -> row.getBuyerTaxNo() != null && row.getSalerTaxNo() != null
&& customerTaxNos.contains(row.getBuyerTaxNo().trim())
&& customerTaxNos.contains(row.getSalerTaxNo().trim()))
.toList();
}
/**
* 加载客商档案税号集合(购、销方白名单来源):status=1 有效客商的统一社会信用代码(纳税人识别号)
*/
private Set<String> customerTaxNoSet() {
return customerArchiveMapper.selectList(Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getStatus, 1)
.isNotNull(CustomerArchive::getUnifiedCreditCode)).stream()
.map(CustomerArchive::getUnifiedCreditCode)
.filter(Func::isNotEmpty)
.map(String::trim)
.collect(Collectors.toSet());
}
private KingdeeInvoicePool mirrorByInvoiceNo(String invoiceNo) {
return invoicePoolMapper.selectOne(Wrappers.<KingdeeInvoicePool>lambdaQuery()
.eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo)
.last("limit 1"));
}
private String buildAttachmentJson(BfiInvoicePoolVO row) {
if (Func.isEmpty(row.getDownloadUrl())) {
return null;
}
Map<String, Object> attachment = new LinkedHashMap<>();
attachment.put("source", "piaozone");
attachment.put("path", row.getDownloadUrl());
attachment.put("name", row.getInvoiceNo() + ".pdf");
return JsonUtil.toJson(attachment);
}
/**
* 税率优先取顶层小数换算为百分数(0.13 -> 13),顶层为空回退首条明细,均无效时置 0(镜像列非空约束)
*/
private BigDecimal resolveTaxRate(BfiInvoicePoolVO row) {
BigDecimal taxRate = parsePercent(row.getTaxRate());
if (taxRate == null) {
taxRate = Optional.ofNullable(row.getItems()).orElse(List.of()).stream()
.map(BfiInvoicePoolItemVO::getTaxRate)
.filter(StringUtil::isNotBlank)
.findFirst()
.map(this::parsePercent)
.orElse(null);
}
return taxRate == null ? BigDecimal.ZERO : taxRate;
}
private BigDecimal parsePercent(String value) {
if (StringUtil.isBlank(value)) {
return null;
}
try {
return new BigDecimal(value.trim()).multiply(BigDecimal.valueOf(100));
} catch (NumberFormatException exception) {
log.warn("金蝶票池发票税率解析失败:{}", value);
return null;
}
}
private BigDecimal parseAmount(String value) {
if (StringUtil.isBlank(value)) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(value.trim()).setScale(2, RoundingMode.HALF_UP);
} catch (NumberFormatException exception) {
log.warn("金蝶票池发票金额解析失败:{}", value);
return BigDecimal.ZERO;
}
}
private LocalDate parseDate(String value) {
if (StringUtil.isBlank(value)) {
return null;
}
try {
return LocalDate.parse(value.trim().substring(0, 10));
} catch (Exception exception) {
log.warn("金蝶票池发票开票日期解析失败:{}", value);
return null;
}
}
private LocalDateTime parseDateTime(String value) {
if (StringUtil.isBlank(value)) {
return null;
}
try {
return LocalDateTime.parse(value.trim().replace(" ", "T").substring(0, 19));
} catch (Exception exception) {
return null;
}
}
private String truncate(String value, int length) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.length() > length ? trimmed.substring(0, length) : trimmed;
}
/** 操作人:定时任务上下文无登录用户时以「定时任务」名义记录流水 */
private String currentOperator() {
String userName = AuthUtil.getUserName();
return Func.isEmpty(userName) ? "定时任务" : userName;
}
/** 自代理:跨方法调用确保事务注解生效(定时绑定逐单独立事务) */
private IInvoiceReceiptService self() {
return SpringUtil.getBean(IInvoiceReceiptService.class);
}
private InvoiceReceiptVO toListVO(InvoiceReceipt entity) {
@@ -609,13 +1188,18 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private void record(Long receiptId, String actionType, String actionName, String fromStatus,
String toStatus, String reason, String kingdeeBillNo) {
record(receiptId, actionType, actionName, fromStatus, toStatus, reason, kingdeeBillNo, AuthUtil.getUserName());
}
private void record(Long receiptId, String actionType, String actionName, String fromStatus,
String toStatus, String reason, String kingdeeBillNo, String operatorName) {
InvoiceReceiptRecord record = new InvoiceReceiptRecord();
record.setInvoiceReceiptId(receiptId);
record.setActionType(actionType);
record.setActionName(actionName);
record.setFromStatus(fromStatus);
record.setToStatus(toStatus);
record.setOperatorName(AuthUtil.getUserName());
record.setOperatorName(operatorName);
record.setReason(reason);
record.setKingdeeBillNo(kingdeeBillNo);
recordMapper.insert(record);
@@ -0,0 +1,70 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.transport.service.IInvoiceReceiptService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 收票管理金蝶全票池发票定时绑定任务
* <p>
* 定时查询金蝶发票云全票池发票数据(A801-1,经 BFI 网关 rim 契约透传),
* 按发票号码 upsert 票据池镜像并与收票登记绑定关联,回填金蝶流水号与同步状态。
* 业务逻辑收敛在 {@link IInvoiceReceiptService#syncKingdeeInvoicePool()},便于手动触发联调。
* <p>
* 调度配置(Nacos thirdParty.bfi.invoiceReceipt.*):syncEnabled 开关、cron 表达式、incrementDays 增量窗口。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InvoiceReceiptKingdeeSyncTask {
private final IInvoiceReceiptService invoiceReceiptService;
/** 全票池绑定任务开关,默认关闭(联调稳定后开启) */
@Value("${thirdParty.bfi.invoiceReceipt.syncEnabled:false}")
private boolean syncEnabled;
/** 金蝶全票池发票绑定调度,默认每30分钟执行一次 */
@Scheduled(cron = "${thirdParty.bfi.invoiceReceipt.cron:0 */30 * * * ?}")
public void syncKingdeeInvoicePool() {
if (!syncEnabled) {
return;
}
try {
String summary = invoiceReceiptService.syncKingdeeInvoicePool();
log.info("定时绑定金蝶全票池发票:{}", summary);
} catch (Exception exception) {
log.error("定时绑定金蝶全票池发票异常", exception);
}
}
}