✨ 收票管理对接金蝶全票池(A801-1):批量同步/定时绑定/补偿兜底
批量同步按钮改调 A801-1 按发票号码精确查询并回填绑定;新增定时绑定任务(滚动增量窗口分页、镜像批量 upsert、绑定页级事务批量落库、发票种类与客商税号白名单过滤、未绑定登记按号补偿兜底);新增手动触发端点 /invoice-receipt/sync-invoice-pool
This commit is contained in:
+7
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -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);
|
||||
}
|
||||
|
||||
+595
-11
@@ -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 -> 镜像,新增/更新条数)
|
||||
*/
|
||||
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 -> 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);
|
||||
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user