From f13f983467e4e01b29da9d74d1e1f70d9eb432b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 21 Sep 2026 13:18:52 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E6=94=B6=E7=A5=A8=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=AF=B9=E6=8E=A5=E9=87=91=E8=9D=B6=E5=85=A8=E7=A5=A8?= =?UTF-8?q?=E6=B1=A0=EF=BC=88A801-1=EF=BC=89=EF=BC=9A=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=90=8C=E6=AD=A5/=E5=AE=9A=E6=97=B6=E7=BB=91=E5=AE=9A/?= =?UTF-8?q?=E8=A1=A5=E5=81=BF=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批量同步按钮改调 A801-1 按发票号码精确查询并回填绑定;新增定时绑定任务(滚动增量窗口分页、镜像批量 upsert、绑定页级事务批量落库、发票种类与客商税号白名单过滤、未绑定登记按号补偿兜底);新增手动触发端点 /invoice-receipt/sync-invoice-pool --- .../controller/InvoiceReceiptController.java | 7 + .../service/IInvoiceReceiptService.java | 28 + .../impl/InvoiceReceiptServiceImpl.java | 606 +++++++++++++++++- .../task/InvoiceReceiptKingdeeSyncTask.java | 70 ++ 4 files changed, 700 insertions(+), 11 deletions(-) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java index 1a01d3c0..4819ecfc 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java @@ -154,4 +154,11 @@ public class InvoiceReceiptController extends BladeController { public R syncKingdee(@RequestParam Long id) { return R.data(invoiceReceiptService.syncKingdee(id)); } + + @PostMapping("/sync-invoice-pool") + @ApiOperationSupport(order = 13) + @Operation(summary = "手动触发金蝶全票池发票同步绑定") + public R syncInvoicePool() { + return R.data(invoiceReceiptService.syncKingdeeInvoicePool()); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java index c6253ae3..0a45bca0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java @@ -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 { void voidBill(InvoiceReceiptStatusRequest request); String syncKingdee(Long id); + + /** + * 定时查询金蝶全票池发票(A801-1)并绑定收票登记 + * + * @return 执行摘要,含拉取页数、镜像新增/更新、绑定与状态刷新统计 + */ + String syncKingdeeInvoicePool(); + + /** + * 批量应用金蝶票池绑定结果(页级事务):登记绑定批量更新 + 同步流水批量写入,整页原子生效 + * + * @param receipts 已回填绑定字段的收票登记 + * @param records 同步流水 + */ + void applyInvoicePoolSyncBatch(List receipts, List 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); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index 2d254ea9..f43dd196 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -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 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 selectPage(IPage page, InvoiceReceiptVO query) { @@ -329,22 +382,548 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl 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 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 rows = queryPoolPage(windowStart, windowEnd, pageNo); + if (rows.isEmpty()) { + break; + } + pulledCount += rows.size(); + // 发票种类白名单 + 客商税号白名单(购、销双方)过滤(翻页终止仍按原始页条数判断) + List typeAllowedRows = filterByInvoiceTypes(rows); + filteredCount += rows.size() - typeAllowedRows.size(); + List allowedRows = filterByArchiveTaxNos(customerTaxNos, typeAllowedRows); + salerFilteredCount += typeAllowedRows.size() - allowedRows.size(); + // ② 镜像批量 upsert:票池为唯一事实源,无论是否已登记均刷新镜像(批量查改,避免逐条 N+1) + BatchUpsertResult upsertResult = batchUpsertInvoicePool(allowedRows); + Map mirrorMap = upsertResult.mirrors(); + created += upsertResult.created(); + updated += upsertResult.updated(); + // ③ 同页发票号码批量定位收票登记,内存回填绑定后页级批量落库(批量失败整页跳过待下轮) + if (!mirrorMap.isEmpty()) { + Map> receiptMap = list(Wrappers.lambdaQuery() + .in(InvoiceReceipt::getInvoiceNo, mirrorMap.keySet()) + .ne(InvoiceReceipt::getApprovalStatus, VOIDED)).stream() + .filter(receipt -> !Objects.equals(receipt.getIsDeleted(), 1)) + .collect(Collectors.groupingBy(InvoiceReceipt::getInvoiceNo)); + List targets = new ArrayList<>(); + for (Map.Entry 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 unboundReceipts = list(Wrappers.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 invoiceNos = unboundReceipts.stream() + .map(InvoiceReceipt::getInvoiceNo).filter(Func::isNotEmpty).map(String::trim).distinct().toList(); + Map mirrorMap = invoicePoolMapper.selectList( + Wrappers.lambdaQuery() + .in(KingdeeInvoicePool::getInvoiceNo, invoiceNos)).stream() + .collect(Collectors.toMap(KingdeeInvoicePool::getInvoiceNo, Function.identity(), (first, second) -> first)); + // ② 内存回填绑定;本地镜像缺失的按发票号码精确补查,新增镜像攒批 + List targets = new ArrayList<>(); + List 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 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 receipts, List 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 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 page = bfiService.queryInvoicePool(param); + return page.getRows() == null ? List.of() : page.getRows(); + } + + /** + * A801-1 按票池更新时间窗口分页拉取 + */ + private List 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 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 rows) { + Map mirrorMap = new LinkedHashMap<>(); + if (rows.isEmpty()) { + return new BatchUpsertResult(mirrorMap, 0, 0); + } + List invoiceNos = rows.stream() + .map(row -> row.getInvoiceNo().trim()) + .distinct() + .toList(); + // 同页发票号码一次批量查询既有镜像 + Map existingMap = invoicePoolMapper.selectList( + Wrappers.lambdaQuery() + .in(KingdeeInvoicePool::getInvoiceNo, invoiceNos)).stream() + .collect(Collectors.toMap(KingdeeInvoicePool::getInvoiceNo, Function.identity(), (first, second) -> first)); + List toInsert = new ArrayList<>(); + List 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 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 filterByInvoiceTypes(List rows) { + Set allowed = allowedInvoiceTypes(); + if (allowed.isEmpty()) { + return rows; + } + return rows.stream() + .filter(row -> allowed.contains(row.getInvoiceType() == null ? "" : row.getInvoiceType().trim())) + .toList(); + } + + private Set allowedInvoiceTypes() { + if (StringUtil.isBlank(invoiceTypes)) { + return Set.of(); + } + return Arrays.stream(invoiceTypes.split("[,,;;]")) + .map(String::trim) + .filter(StringUtil::isNotBlank) + .collect(Collectors.toSet()); + } + + /** + * 客商税号白名单过滤:购、销双方税号均须命中客商档案税号集合,集合为空不限制;白名单外发票不入镜像不绑定 + */ + private List filterByArchiveTaxNos(Set customerTaxNos, List 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 customerTaxNoSet() { + return customerArchiveMapper.selectList(Wrappers.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.lambdaQuery() + .eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo) + .last("limit 1")); + } + + private String buildAttachmentJson(BfiInvoicePoolVO row) { + if (Func.isEmpty(row.getDownloadUrl())) { + return null; + } + Map 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 + * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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; + +/** + * 收票管理金蝶全票池发票定时绑定任务 + *

+ * 定时查询金蝶发票云全票池发票数据(A801-1,经 BFI 网关 rim 契约透传), + * 按发票号码 upsert 票据池镜像并与收票登记绑定关联,回填金蝶流水号与同步状态。 + * 业务逻辑收敛在 {@link IInvoiceReceiptService#syncKingdeeInvoicePool()},便于手动触发联调。 + *

+ * 调度配置(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); + } + } +}