✨ 合并收款认领合规推送,金蝶实际付款后再同步履约

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 16:44:46 +08:00
47 changed files with 2262 additions and 1038 deletions
@@ -71,9 +71,9 @@ public class BfiProperties {
private ReceivedInvoice receivedInvoice = new ReceivedInvoice();
/**
* 收款认领公告查询配置(A301-2)
* 收款流水同步配置(A301-1 收款单查询)
*/
private ClaimAnnounce claimAnnounce = new ClaimAnnounce();
private RecBillSync recBillSync = new RecBillSync();
/**
* 付款结果定时回写配置(A103-2/A103-4,总开关同时控制应收勾稽、开票结果回写)
@@ -85,11 +85,6 @@ public class BfiProperties {
*/
private ReceivedInvoiceResult receivedInvoiceResult = new ReceivedInvoiceResult();
/**
* 收款认领推送配置(A301-3)
*/
private ReceiptClaimPush receiptClaimPush = new ReceiptClaimPush();
/**
* 全票池发票定时同步配置(A801-1)
*/
@@ -197,20 +192,15 @@ public class BfiProperties {
}
/**
* 收款认领公告查询配置项(A301-2)
* 收款流水同步配置项(A301-1 收款单查询)
*/
@Data
public static class ClaimAnnounce {
public static class RecBillSync {
/**
* 查询时间窗口(月数),默认 6(近半年)
* 同步业务日期窗口天数,默认 60
*/
private int months = 6;
/**
* 认领人(claimusers):配置后公告查询固定使用该值,不再按当前登录用户姓名查询
*/
private String claimusers;
private int days = 60;
}
/**
@@ -257,6 +247,11 @@ public class BfiProperties {
* 回写 single-flight 锁租约秒数,须大于最坏一轮耗时(分批远程查询 + 逐单回写)
*/
private long lockLeaseSeconds = 1800;
/**
* A103-4 付款处理查询业务日期窗口天数,默认 60
*/
private int queryWindowDays = 60;
}
/**
@@ -271,23 +266,6 @@ public class BfiProperties {
private boolean syncEnabled = false;
}
/**
* 收款认领推送配置项(A301-3)
*/
@Data
public static class ReceiptClaimPush {
/**
* 推送任务开关,默认关闭(联调稳定后开启)
*/
private boolean syncEnabled = false;
/**
* 推送 single-flight 锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告 + 逐条推送)
*/
private long lockLeaseSeconds = 1800;
}
/**
* 全票池发票同步配置项(A801-1)
*/
@@ -83,16 +83,9 @@ public class ReceiptClaimRecordController extends BladeController {
@PostMapping("/void")
@ApiOperationSupport(order = 4)
@Operation(summary = "作废认领记录并生成金蝶认领冲单")
@Operation(summary = "作废认领记录(作废后同步合同合规系统)")
public R<Void> voidClaim(@RequestParam Long id) {
receiptClaimRecordService.voidClaim(id);
return R.success("作废成功");
}
@PostMapping("/query-kingdee-result")
@ApiOperationSupport(order = 5)
@Operation(summary = "查询金蝶认领结果(A301-1 收款单查询回写)")
public R<Integer> queryKingdeeResult() {
return R.data(receiptClaimRecordService.refreshKingdeeResult());
}
}
@@ -35,7 +35,6 @@ import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.ReceiptClaimPushResult;
import org.springblade.transport.pojo.dto.ReceiptClaimRequest;
import org.springblade.transport.pojo.vo.ReceiptFlowVO;
import org.springblade.transport.service.IReceiptFlowService;
@@ -95,16 +94,8 @@ public class ReceiptFlowController extends BladeController {
@PostMapping("/sync")
@ApiOperationSupport(order = 5)
@Operation(summary = "手动同步金蝶收款流水(A301-2 收款认领公告查询)")
@Operation(summary = "手动同步金蝶收款流水(A301-1 收款单查询)")
public R<Integer> sync() {
return R.data(receiptFlowService.sync());
}
@PostMapping("/push-kingdee")
@ApiOperationSupport(order = 6)
@Operation(summary = "推送金蝶收款认领(A301-3 收款认领结果推送,仅认领完成流水可推送)")
public R<ReceiptClaimPushResult> pushKingdee(@RequestParam Long flowId) {
ReceiptClaimPushResult result = receiptFlowService.pushClaimToKingdee(flowId);
return result.isSuccess() ? R.data(result) : R.fail(result.getMessage());
}
}
@@ -27,8 +27,11 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
import java.util.List;
/**
* 金蝶收款流水镜像 Mapper 接口
*
@@ -36,4 +39,13 @@ import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
*/
@Mapper
public interface KingdeeReceiptFlowMapper extends BaseMapper<KingdeeReceiptFlow> {
/**
* 组织管理(blade_dept)中的平台公司金蝶组织编码列表(是否平台公司=是 且 已维护金蝶编码)
*
* @return 平台公司金蝶组织编码
*/
@Select("SELECT kingdee_dept_code FROM blade_dept WHERE is_platform_company = 1 "
+ "AND kingdee_dept_code IS NOT NULL AND kingdee_dept_code != '' AND is_deleted = 0")
List<String> selectPlatformOrgNumbers();
}
@@ -0,0 +1,39 @@
/**
* 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.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.springblade.transport.pojo.entity.ReceiptClaimHcsRecord;
/**
* 收款认领合规推送记录 Mapper 接口
*
* @author Chill
*/
@Mapper
public interface ReceiptClaimHcsRecordMapper extends BaseMapper<ReceiptClaimHcsRecord> {
}
@@ -46,11 +46,4 @@ public interface IReceiptClaimRecordService extends BaseService<ReceiptClaim> {
void updateAttachments(ReceiptClaimAttachmentsRequest request);
void voidClaim(Long id);
/**
* A301-1 收款单查询回写认领结果:按来源单据编码匹配金蝶收款单,推进金蝶单据状态
*
* @return 回写条数
*/
int refreshKingdeeResult();
}
@@ -27,7 +27,6 @@ package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.ReceiptClaimPushResult;
import org.springblade.transport.pojo.dto.ReceiptClaimRequest;
import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
import org.springblade.transport.pojo.vo.ReceiptFlowVO;
@@ -53,25 +52,9 @@ public interface IReceiptFlowService extends BaseService<KingdeeReceiptFlow> {
Long claim(ReceiptClaimRequest request);
/**
* 手动同步金蝶收款流水:直连 A301-2 收款认领公告查询,按明细流水号幂等 upsert 收款流水表
* 手动同步金蝶收款流水:直连 A301-1 收款单查询(收款处理查询),按流水分录明细流水号幂等 upsert 收款流水表
*
* @return 本次同步条数
*/
int sync();
/**
* 手动推送金蝶(A301-3 收款认领结果推送):认领完成且未推送过的流水才可推送,
* 推送前以当前用户姓名重查 A301-2 最新公告对账。与定时任务共用同一推送实现。
*
* @param flowId 收款流水id
* @return 推送结果(失败不抛异常,原因随结果返回)
*/
ReceiptClaimPushResult pushClaimToKingdee(Long flowId);
/**
* 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。
*
* @return 执行摘要
*/
String pushClaimByTask();
}
@@ -50,6 +50,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.config.BfiProperties;
@@ -1304,11 +1305,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, customerTaxNos != null ? "开启" : "关闭",
POOL_PAGE_SIZE, MAX_POOL_PAGES);
if (invoiceTypes.isEmpty()) {
this.syncWindowSlice(windowStart, windowEnd, null, customerTaxNos, stats);
this.syncPoolWindow(windowStart, windowEnd, null, customerTaxNos, stats);
} else {
// 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度
for (String invoiceType : invoiceTypes) {
this.syncWindowSlice(windowStart, windowEnd, invoiceType, customerTaxNos, stats);
this.syncPoolWindow(windowStart, windowEnd, invoiceType, customerTaxNos, stats);
}
}
String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取"
@@ -1358,72 +1359,51 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/**
* 同步单个时间片 [sliceStart, sliceEnd):独立翻页拉取(最多 MAX_POOL_PAGES 页),
* 触顶说明该时间片数据 >2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛,
* 单片 500 条以内 1 页即短页终止)
* 同步单个票种的时间窗口:翻页与触顶二分续扫统一交给 {@link BfiUtil#forEachWindowSlice},
* 页级客商单位过滤、镜像批量 upsert、已登记票跟随补绑在页回调中完成
*
* @param sliceStart 切片起点(含)
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享)
* @param stats 整轮统计累加器
* @param windowStart 窗口起点(含)
* @param windowEnd 窗口终点(含)
* @param invoiceType 发票种类编码,null 不限
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享)
* @param stats 整轮统计累加器
*/
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType,
private void syncPoolWindow(LocalDateTime windowStart, LocalDateTime windowEnd, String invoiceType,
Set<String> customerTaxNos, SyncStats stats) {
// 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏
if (!sliceStart.isBefore(sliceEnd)) {
log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd);
return;
}
boolean truncated = false;
for (int pageNo = 1; pageNo <= MAX_POOL_PAGES; pageNo++) {
List<BfiInvoicePoolVO> rows = this.pullPoolWindowPage(sliceStart, sliceEnd, invoiceType, pageNo);
if (rows.isEmpty()) {
break;
}
stats.pageCount++;
stats.pulledCount += rows.size();
// 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像;
// 短页判定用原始条数,过滤后的空页不能提前终止翻页
stats.sliceCount += BfiUtil.forEachWindowSlice("金蝶全票池同步", windowStart, windowEnd,
POOL_PAGE_SIZE, MAX_POOL_PAGES,
(sliceStart, sliceEnd, pageNo) -> this.pullPoolWindowPage(sliceStart, sliceEnd, invoiceType, pageNo),
rows -> this.handlePoolPage(rows, customerTaxNos, stats));
}
/**
* 票池同步单页处理:客商单位过滤 + 镜像批量 upsert + 已登记票跟随补绑。
* 客商过滤只影响入镜像行数,翻页节奏由 BfiUtil 按原始行数判断,不受过滤影响
*
* @param rows 本页拉取的票池发票(原始行)
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭
* @param stats 整轮统计累加器
*/
private void handlePoolPage(List<BfiInvoicePoolVO> rows, Set<String> customerTaxNos, SyncStats stats) {
stats.pageCount++;
stats.pulledCount += rows.size();
// 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像
if (customerTaxNos != null) {
int rawPageSize = rows.size();
if (customerTaxNos != null) {
rows = rows.stream().filter(row -> this.bothUnitsRegistered(row, customerTaxNos)).toList();
stats.customerFiltered += rawPageSize - rows.size();
}
BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows);
stats.created += upsertResult.created();
stats.updated += upsertResult.updated();
stats.mirrorUnchanged += upsertResult.unchanged();
// 已登记票跟随补绑:同页发票号码批量定位登记,绑定关系变更页级事务批量落库
if (!upsertResult.mirrors().isEmpty()) {
RefreshResult refreshResult = this.refreshBoundReceipts(stats.pageCount, upsertResult.mirrors());
stats.bound += refreshResult.bound();
stats.unchanged += refreshResult.unchanged();
stats.failed += refreshResult.failed();
}
// 远程短页:该片到底,正常终止(按原始拉取条数判断,客商过滤不改变翻页节奏)
if (rawPageSize < POOL_PAGE_SIZE) {
stats.sliceCount++;
return;
}
truncated = true;
rows = rows.stream().filter(row -> this.bothUnitsRegistered(row, customerTaxNos)).toList();
stats.customerFiltered += rawPageSize - rows.size();
}
// 触顶:该切片数据超过单查询窗口上限,从中点二分续扫(updateTime 落在中点的行归左片 [start, mid),
// 右片 [mid, end) 不含 mid,同 updateTime 行整体归左片,不重不漏)
if (truncated) {
LocalDateTime mid = sliceStart.plus(java.time.Duration.between(sliceStart, sliceEnd).dividedBy(2));
if (!mid.isAfter(sliceStart)) {
// 切片已细到不足 1 秒仍满页:理论不可能,防御死递归
log.error("金蝶全票池同步切片 {} ~ {} 已达最小粒度仍满页(>{} 条),该时间片数据异常,跳过",
sliceStart, sliceEnd, MAX_POOL_PAGES * POOL_PAGE_SIZE);
return;
}
log.warn("金蝶全票池同步切片 {} ~ {} 触达翻页保护 {} 页({} 条),二分续扫 [{} ~ {}) + [{} ~ {})",
sliceStart, sliceEnd, MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE, sliceStart, mid, mid, sliceEnd);
this.syncWindowSlice(sliceStart, mid, invoiceType, customerTaxNos, stats);
this.syncWindowSlice(mid, sliceEnd, invoiceType, customerTaxNos, stats);
return;
BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows);
stats.created += upsertResult.created();
stats.updated += upsertResult.updated();
stats.mirrorUnchanged += upsertResult.unchanged();
// 已登记票跟随补绑:同页发票号码批量定位登记,绑定关系变更页级事务批量落库
if (!upsertResult.mirrors().isEmpty()) {
RefreshResult refreshResult = this.refreshBoundReceipts(stats.pageCount, upsertResult.mirrors());
stats.bound += refreshResult.bound();
stats.unchanged += refreshResult.unchanged();
stats.failed += refreshResult.failed();
}
stats.sliceCount++;
}
/**
@@ -1523,7 +1503,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/** A801-1 按票池更新时间窗口分页拉取(只认正常票,不推单据使用状态;invoiceType 单值下推,null 不限) */
private List<BfiInvoicePoolVO> pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd,
private BfiPageVO<BfiInvoicePoolVO> pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd,
String invoiceType, int pageNo) {
BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO();
data.setPageNo(pageNo);
@@ -1536,7 +1516,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
param.setQueryParam(data);
BfiPageVO<BfiInvoicePoolVO> poolPage = bfiService.queryInvoicePool(param);
log.info("金蝶全票池同步 第{}页拉取 {} 条", pageNo, poolPage.getRows() == null ? 0 : poolPage.getRows().size());
return poolPage.getRows() == null ? List.of() : poolPage.getRows();
return poolPage;
}
/**
@@ -1185,15 +1185,17 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
/**
* A103-4 分页查询:按付款人(公司)编码 + 最近1个月业务日期窗口(接口限制不超过1个月),自动翻页
* A103-4 分页查询:按付款人(公司)编码 + 业务日期窗口,自动翻页
* <p>
* 窗口天数取 Nacos thirdParty.bfi.payResult.queryWindowDays(默认 60)
*
* @param orgNumber 付款人(公司)编码
* @return 付款处理单列表
*/
private List<BfiPayBillVO> queryPayBillPages(String orgNumber) {
LocalDate endDate = LocalDate.now();
// 业务日期窗口控制在不超过1个月
LocalDate beginDate = endDate.minusMonths(1).plusDays(1);
int windowDays = Math.max(bfiProperties.getPayResult().getQueryWindowDays(), 1);
LocalDate beginDate = endDate.minusDays(windowDays);
BfiPayBillQueryDTO param = new BfiPayBillQueryDTO();
param.setOrgNumber(List.of(orgNumber));
param.setBeginBizdate(beginDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
@@ -33,12 +33,6 @@ 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.utils.Func;
import org.springblade.thirdparty.bfi.constant.BfiConstant;
import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.KingdeeReceiptFlowMapper;
import org.springblade.transport.mapper.ReceiptClaimMapper;
@@ -53,20 +47,21 @@ import org.springblade.transport.pojo.entity.ReceiptFlowRecord;
import org.springblade.transport.pojo.vo.ReceiptClaimRecordVO;
import org.springblade.transport.service.IReceiptClaimRecordService;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.support.hcs.HcsClaimSyncSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -84,21 +79,13 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
private static final String CLAIMED = "claimed";
private static final String VOIDED = "voided";
private static final String APPROVED = "approved";
/**
* A301-1 收款单查询回看窗口天数(金蝶约束业务日期范围不可超过1个月)
*/
private static final int RESULT_WINDOW_DAYS = 30;
/**
* A301-1 收款单查询分页大小
*/
private static final int RESULT_PAGE_SIZE = 200;
private final ReceiptClaimSettlementMapper claimSettlementMapper;
private final IFormalSettlementService formalSettlementService;
private final KingdeeReceiptFlowMapper receiptFlowMapper;
private final FormalSettlementMapper formalSettlementMapper;
private final ReceiptFlowRecordMapper recordMapper;
private final IBfiService bfiService;
private final HcsClaimSyncSupport hcsClaimSyncSupport;
@Override
public IPage<ReceiptClaimRecordVO> selectPage(IPage<ReceiptClaimRecordVO> page,
@@ -203,10 +190,6 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
if (flow == null || Objects.equals(flow.getIsDeleted(), 1)) {
throw new ServiceException("关联收款流水不存在");
}
// 已推送金蝶的认领不允许作废:金蝶收款单已生成,冲销/调整由人工在金蝶处置
if (KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED.equals(flow.getPushStatus())) {
throw new ServiceException("该认领已推送金蝶,不允许作废,请在金蝶侧人工处置");
}
List<ReceiptClaimSettlement> relations = claimSettlementMapper.selectList(
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
@@ -256,9 +239,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, claimCeiling(flow)));
receiptFlowMapper.updateById(flow);
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息),
// 金蝶侧冲销/红冲由人工处置;冲单逻辑后续迭代如需恢复,放开下行调用即可
// String kingdeeBillNo = buildKingdeeBillNo(claim.getId());
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(金蝶收款单已在 A301-1 同步落库),
// 金蝶侧冲销/红冲由人工处置
claim.setClaimStatus(VOIDED);
claim.setVoidedBy(AuthUtil.getUserId());
claim.setVoidedByName(Func.isEmpty(AuthUtil.getUserName()) ? claim.getClaimerName()
@@ -278,108 +260,14 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
: AuthUtil.getUserName());
operationRecord.setContent("作废认领,回退认领金额 " + money(claim.getClaimAmount()));
recordMapper.insert(operationRecord);
}
/**
* A301-1 收款单查询回写认领结果:按来源单据编码匹配金蝶收款单,推进流水金蝶回写状态
* <p>
* 回写口径:A301-1 契约本身不支持按来源单号过滤,故按近 30 天业务日期窗口全量翻页后,
* 以金蝶收款单 sourcebillnumber == 流水 awrp_src_code(A301-3 推送时按流水生成、重试复用)在本地匹配;
* 仅推进金蝶回写状态与补登收款单号,不改本地认领金额/分摊;名下未作废认领主单冗余展示字段级联刷新。
*
* @return 回写条数
*/
@Override
public int refreshKingdeeResult() {
List<KingdeeReceiptFlow> pending = receiptFlowMapper.selectList(
Wrappers.<KingdeeReceiptFlow>lambdaQuery()
.eq(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED)
.in(KingdeeReceiptFlow::getKingdeeBillStatus,
List.of(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED, APPROVED))
.eq(KingdeeReceiptFlow::getStatus, 1)
.orderByAsc(KingdeeReceiptFlow::getId));
if (pending.isEmpty()) {
log.info("金蝶认领结果回写:无待回写流水(已推送/已审核且未终态)");
return 0;
}
Set<String> srcCodes = pending.stream()
.map(KingdeeReceiptFlow::getAwrpSrcCode)
.filter(Func::isNotEmpty)
.collect(Collectors.toSet());
if (srcCodes.isEmpty()) {
return 0;
}
log.info("金蝶认领结果回写:待回写流水 {} 条", pending.size());
LocalDate end = LocalDate.now();
LocalDate begin = end.minusDays(RESULT_WINDOW_DAYS);
long start = System.currentTimeMillis();
log.info("调用BFI接口 收款单查询开始,业务日期窗口 {} ~ {}", begin, end);
List<BfiRecBillVO> rows = BfiUtil.queryAllPages(pageNo -> {
BfiRecBillQueryDTO dto = new BfiRecBillQueryDTO();
dto.setPageNo(pageNo);
dto.setPageSize(RESULT_PAGE_SIZE);
dto.setBeginBizdate(begin.format(DateTimeFormatter.ISO_LOCAL_DATE));
dto.setEndBizdate(end.format(DateTimeFormatter.ISO_LOCAL_DATE));
return bfiService.queryRecBill(dto);
// 作废事务提交后同步合规 C107-3(fdStatus=1 已作废,失败仅记日志不回滚作废)
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
hcsClaimSyncSupport.voidClaimToHcs(claim.getId());
}
});
log.info("调用BFI接口 收款单查询结束 耗时:{}ms,拉取 {} 条", System.currentTimeMillis() - start, rows.size());
// 窗口内收款单按 sourcebillnumber 匹配待回写来源编码,同键多单取首条
Map<String, BfiRecBillVO> matched = rows.stream()
.filter(row -> Func.isNotEmpty(row.getSourcebillnumber()))
.filter(row -> srcCodes.contains(row.getSourcebillnumber()))
.collect(Collectors.toMap(BfiRecBillVO::getSourcebillnumber, Function.identity(),
(first, second) -> first, HashMap::new));
int updated = 0;
for (KingdeeReceiptFlow flow : pending) {
BfiRecBillVO bill = matched.get(flow.getAwrpSrcCode());
if (bill == null) {
// 窗口内未查到:金蝶回单延迟/状态未同步,不改状态,由下次回写或人工处置兜底
continue;
}
// 推送成功时金蝶收款单号先以来源编码占位(A301-3 响应 number 为空,取回执 keys.awrp_src_code),
// 回写命中后以金蝶真实收款单号覆盖
if (Func.isNotEmpty(bill.getBillno())) {
flow.setKingdeeBillNo(bill.getBillno());
}
flow.setKingdeeBillStatus(billStatusOf(bill.getBillstatus()));
receiptFlowMapper.updateById(flow);
// 级联刷新名下未作废认领主单的冗余展示字段
baseMapper.update(null, Wrappers.<ReceiptClaim>lambdaUpdate()
.eq(ReceiptClaim::getReceiptFlowId, flow.getId())
.eq(ReceiptClaim::getClaimStatus, CLAIMED)
.set(ReceiptClaim::getKingdeeBillNo, flow.getKingdeeBillNo())
.set(ReceiptClaim::getKingdeeBillStatus, flow.getKingdeeBillStatus()));
updated++;
log.info("金蝶认领结果回写 flowId:{},awrpSrcCode:{},金蝶收款单号:{},状态:{} → {}",
flow.getId(), flow.getAwrpSrcCode(), bill.getBillno(), bill.getBillstatus(),
flow.getKingdeeBillStatus());
}
log.info("金蝶认领结果回写完成:匹配 {} 条,更新 {} 条", matched.size(), updated);
return updated;
}
/**
* 金蝶收款单单据状态 → 本地认领金蝶状态
*
* @param billStatus 金蝶 billstatus(A暂存/B已提交/C已审核/D已收款/G已退单)
* @return 本地状态
*/
private String billStatusOf(String billStatus) {
if (BfiConstant.BILL_STATUS_RECEIVED.equals(billStatus)) {
return KingdeeBfiConstant.KINGDEE_STATUS_RECEIVED;
}
if (BfiConstant.BILL_STATUS_AUDITED.equals(billStatus)) {
return APPROVED;
}
if (BfiConstant.BILL_STATUS_DRAFT.equals(billStatus)
|| BfiConstant.BILL_STATUS_SUBMITTED.equals(billStatus)) {
return KingdeeBfiConstant.KINGDEE_STATUS_PUSHED;
}
// 其余状态(含 G 已退单)按处理失败回收,提示人工在金蝶核查
log.warn("金蝶收款单状态[{}]未匹配映射,认领回写按 failed 处理", billStatus);
return KingdeeBfiConstant.KINGDEE_STATUS_FAILED;
}
private void fillStatusNames(ReceiptClaimRecordVO record) {
@@ -399,17 +287,6 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
return VOIDED.equals(claimStatus) ? VOIDED : CLAIMED;
}
/**
* 生成本地金蝶认领冲单号(KDCX + 时间戳 + 认领id尾号6位)。
* 当前作废流程不生成冲单(调用点已注释保留),后续迭代接金蝶冲销时放开即可。
*/
@SuppressWarnings("unused")
private String buildKingdeeBillNo(Long claimId) {
String time = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
String suffix = String.valueOf(claimId);
return "KDCX" + time + suffix.substring(Math.max(0, suffix.length() - 6));
}
/**
* 累计认领金额 → 流水认领状态:对照可认领上限判定(与收款流水侧口径一致)
*/
@@ -421,18 +298,11 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
}
/**
* 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致)
* 可认领上限:收款金额(A301-1 actrecamt,与收款流水侧口径一致)。
* 认领只登记本地,额度由本地累计认领控制,金蝶未匹配金额不参与。
*/
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
BigDecimal receipt = money(flow.getReceiptAmount());
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
return unclaimed.min(receipt);
return money(flow.getReceiptAmount());
}
private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) {
@@ -38,12 +38,9 @@ import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache;
import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushEntryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
import org.springblade.transport.config.BfiProperties;
@@ -53,7 +50,6 @@ import org.springblade.transport.mapper.KingdeeReceiptFlowMapper;
import org.springblade.transport.mapper.ReceiptClaimMapper;
import org.springblade.transport.mapper.ReceiptClaimSettlementMapper;
import org.springblade.transport.mapper.ReceiptFlowRecordMapper;
import org.springblade.transport.pojo.dto.ReceiptClaimPushResult;
import org.springblade.transport.pojo.dto.ReceiptClaimRequest;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
@@ -63,12 +59,13 @@ import org.springblade.transport.pojo.entity.ReceiptFlowRecord;
import org.springblade.transport.pojo.vo.ReceiptFlowVO;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IReceiptFlowService;
import org.springblade.transport.support.KingdeeSyncLockSupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.hcs.HcsClaimSyncSupport;
import org.springblade.transport.wrapper.ReceiptFlowWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
@@ -77,7 +74,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -109,18 +105,23 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
// 应收款
private static final String RECEIVABLE = "receivable";
/**
* A301-2 认领公告同步分页大小
* A301-1 收款单查询分页大小
*/
private static final int SYNC_PAGE_SIZE = 200;
/**
* A301-2 交易时间格式
* 同步分批入库批次大小:累计满一批即独立事务入库,避免全量驻留内存
*/
private static final int SYNC_BATCH_SIZE = 5000;
/**
* A301-1 单片翻页最大页数保护(200条/页 × 500页):触顶说明该时间片数据超过单查询窗口上限,
* 由 BfiUtil 二分切片续扫保证尾部不漏
*/
private static final int SYNC_MAX_PAGES = 500;
/**
* A301-1 业务日期时间格式(请求参数与返回 bizdate 均为 yyyy-MM-dd HH:mm:ss)
*/
private static final DateTimeFormatter BFI_DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 定时推送收款认领 single-flight 锁键前缀(多副本定时任务共用)
*/
private static final String RECEIPT_CLAIM_PUSH_LOCK_PREFIX = "transport:receipt-flow:kingdee-claim-push:";
private final ReceiptClaimMapper claimMapper;
private final ReceiptClaimSettlementMapper claimSettlementMapper;
@@ -130,7 +131,6 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
private final IBfiService bfiService;
private final BfiProperties bfiProperties;
private final TransactionTemplate transactionTemplate;
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
private final HcsClaimSyncSupport hcsClaimSyncSupport;
@Override
@@ -316,9 +316,6 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
// 收款流水
KingdeeReceiptFlow flow = lockedFlow(request.getFlowId());
if (flow.getBillId() == null) {
throw new ServiceException("认领公告id缺失,请先手动同步金蝶收款流水");
}
// 结算单id → 分摊金额
Map<Long, BigDecimal> allocationMap = allocationMap(request.getSettlements());
// 结算单id列表(升序)
@@ -347,10 +344,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
allocatedTotal = allocatedTotal.add(allocated);
}
// 认领上限:公告未认领金额与收款金额取较小值(避免未认领金额异常偏大导致无法认领完成)
if (flow.getUnclaimedAmount() == null) {
throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水");
}
// 认领上限:收款金额(认领只登记本地,额度由本地累计认领控制,金蝶侧匹配状态不参与)
BigDecimal claimCeiling = claimCeiling(flow);
if (claimCeiling.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("该流水可认领金额为0,无需认领");
@@ -386,8 +380,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
claim.setRemark(trimToNull(request.getRemark()));
// 认领状态
claim.setClaimStatus(CLAIMED);
// 金蝶回写状态(待推送)
claim.setKingdeeBillStatus(KingdeeBfiConstant.RECEIPT_CLAIM_BILL_NONE);
// 金蝶收款单号/状态(冗余展示:A301-1 同步落库的单据信息)
claim.setKingdeeBillNo(flow.getKingdeeBillNo());
claim.setKingdeeBillStatus(flow.getKingdeeBillStatus());
claimMapper.insert(claim);
for (FormalSettlement settlement : settlements) {
@@ -436,149 +431,241 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
record(flow.getId(), claim.getId(), "claim", "认领收款流水", fromStatus, toStatus,
allocatedTotal, "关联" + settlements.size() + "张应收正式结算单");
// 认领只登记本地,不推送金蝶;推送由定时任务/手动按钮在认领完成后触发
// 认领只登记本地,不推送金蝶;事务提交后按认领主单推送合同合规(C103-9/C107-1/C108-1,失败仅落标记)
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
hcsClaimSyncSupport.pushClaimToHcs(claim.getId());
}
});
return claim.getId();
}
/**
* 手动同步金蝶收款流水:直连 A301-1 收款单查询,收款单主单粒度、以金蝶收款单号为幂等键 upsert;
* 数据量大,时间窗口二分切片续扫(BfiUtil.forEachWindowSlice):单片触顶自动二分续扫,
* 总覆盖不受单查询窗口上限约束,尾部不漏;累计满一批(5000 条流水行)即独立事务入库一批,
* 不全量驻留内存
*
* @return 本次同步条数(新增+更新)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int sync() {
String claimusers = UserCache.getUserRealName(AuthUtil.getUserId());
if (Func.isEmpty(claimusers)) {
throw new ServiceException("当前用户无姓名,无法同步金蝶认领公告");
int days = bfiProperties.getRecBillSync().getDays();
LocalDate endDate = LocalDate.now();
LocalDate startDate = endDate.minusDays(days);
// 金蝶 A301-1 仅接受带时分秒的日期参数,传纯日期会报 bos.filterSqlBuilder 时区时间格式错误
LocalDateTime windowStart = startDate.atStartOfDay();
LocalDateTime windowEnd = endDate.atTime(23, 59, 59);
log.info("收款流水同步开始,业务日期窗口:{} ~ {}(近{}天)",
windowStart.format(BFI_DATE_TIME_FORMATTER), windowEnd.format(BFI_DATE_TIME_FORMATTER), days);
long start = System.currentTimeMillis();
// 同步口径:按业务日期窗口拉取金蝶收款单(收款处理),以金蝶明细流水号为幂等键 upsert 收款流水表;
// 本地已认领金额/状态(claimedAmount/claimStatus)由认领、作废驱动,不被同步顺带覆盖,
// 未匹配收款金额单独存 unmatch_amount_rec,仅作金蝶参考展示,不参与认领额度。
// 按组织过滤:组织管理(blade_dept)中平台公司(是否平台公司=是)且已维护金蝶编码的组织
List<String> orgNumbers = baseMapper.selectPlatformOrgNumbers();
if (Func.isEmpty(orgNumbers)) {
throw new ServiceException("组织管理中未维护平台公司金蝶编码,无法同步金蝶收款流水,请先检查组织数据");
}
List<BfiClaimAnnounceVO> announces = queryClaimAnnounces(claimusers);
if (announces.isEmpty()) {
record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO,
"金蝶认领公告未查询到数据");
return 0;
log.info("收款流水同步按组织过滤,组织编码:{}", orgNumbers);
RecBillSyncState state = new RecBillSyncState();
state.sliceCount += BfiUtil.forEachWindowSlice("收款流水同步", windowStart, windowEnd,
SYNC_PAGE_SIZE, SYNC_MAX_PAGES,
(sliceStart, sliceEnd, pageNo) -> this.pullRecBillPage(sliceStart, sliceEnd, orgNumbers, pageNo),
rows -> this.handleRecBillPage(rows, state));
// 尾批不足 SYNC_BATCH_SIZE 的剩余入库
if (!state.pending.isEmpty()) {
state.batchNo++;
state.totalCount += this.syncBatch(state.batchNo, state.pending);
state.pending.clear();
state.batchSerials.clear();
}
if (state.pullCount == 0) {
this.record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO,
"金蝶收款单未查询到数据");
} else if (state.totalCount == 0) {
this.record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO,
"金蝶收款单无有效数据");
}
log.info("金蝶收款流水同步完成,翻页{}页切片{}片,拉取收款单 {} 张,新增/更新 {} 条,跳过 {} 条,"
+ "分 {} 批入库,耗时 {}ms",
state.pageCount, state.sliceCount, state.pullCount, state.totalCount, state.skippedCount,
state.batchNo, System.currentTimeMillis() - start);
return state.totalCount;
}
// 同步口径:A301-2 按参与认领用户(当前登录用户真实姓名)拉取全量认领公告,
// 以金蝶明细流水号为幂等键 upsert 收款流水表;本地已认领金额/状态(claimedAmount/claimStatus)
// 由认领、作废驱动,不被公告顺带覆盖,公告侧状态单独存 kingdee_claim_status/unclaimed_amount。
/**
* A301-1 按业务日期时间片分页拉取(组织编码过滤整轮共享,切片起止由 BfiUtil 二分传入)
*
* @param sliceStart 切片起点(含)
* @param sliceEnd 切片终点(含)
* @param orgNumbers 平台公司组织编码集
* @param pageNo 页码,从 1 开始
* @return 分页结果
*/
private BfiPageVO<BfiRecBillVO> pullRecBillPage(LocalDateTime sliceStart, LocalDateTime sliceEnd,
List<String> orgNumbers, int pageNo) {
BfiRecBillQueryDTO queryDTO = new BfiRecBillQueryDTO();
queryDTO.setPageNo(pageNo);
queryDTO.setPageSize(SYNC_PAGE_SIZE);
queryDTO.setBeginBizdate(sliceStart.format(BFI_DATE_TIME_FORMATTER));
queryDTO.setEndBizdate(sliceEnd.format(BFI_DATE_TIME_FORMATTER));
queryDTO.setOrgNumber(orgNumbers);
return bfiService.queryRecBill(queryDTO);
}
// 第一遍:数据校验与过滤,收集有效的 detailSerialNo
log.info("收款流水同步开始数据校验,共 {} 条认领公告", announces.size());
Map<String, BfiClaimAnnounceVO> validAnnounces = new LinkedHashMap<>();
int skippedCount = 0;
for (BfiClaimAnnounceVO announce : announces) {
// 明细流水号优先级:detailid > tradedetailno > bankdetailno
String detailSerialNo = firstNonBlank(announce.getDetailid(),
announce.getTradedetailno(), announce.getBankdetailno());
/**
* A301-1 单页数据处理:收款单主单粒度逐行校验入缓冲,累计满一批即独立事务入库。
* 切片边界同秒的收款单可能在相邻两片各拉一次,按批内收款单号去重幂等跳过,不重不漏
*
* @param rows 本页拉取的收款单
* @param state 整轮同步状态(待入库缓冲与统计累加)
*/
private void handleRecBillPage(List<BfiRecBillVO> rows, RecBillSyncState state) {
state.pageCount++;
state.pullCount += rows.size();
for (BfiRecBillVO recBill : rows) {
// 主单粒度:一张收款单一条流水,不展开流水分录;明细流水号取收款单号(幂等键,C104-2 按此定位合规流水)
String detailSerialNo = trimToEmpty(recBill.getBillno());
if (Func.isEmpty(detailSerialNo)) {
log.warn("收款流水同步跳过:认领公告[{}]缺少明细流水号", announce.getBillno());
skippedCount++;
log.warn("收款流水同步跳过:收款单缺少单号");
state.skippedCount++;
continue;
}
detailSerialNo = detailSerialNo.trim();
if (validAnnounces.containsKey(detailSerialNo)) {
throw new ServiceException("明细流水号" + detailSerialNo + "重复");
if (!state.batchSerials.add(detailSerialNo)) {
// 切片边界重复拉取或同批脏数据重复单号,幂等跳过(upsert 本就按单号幂等)
log.warn("收款流水同步跳过:收款单[{}]本批已拉取,跳过重复处理", detailSerialNo);
state.skippedCount++;
continue;
}
BigDecimal receiptAmount = money(announce.getReamount());
// 收款金额取主单 actrecamt(金蝶收款金额口径)
BigDecimal receiptAmount = money(recBill.getActrecamt());
if (receiptAmount.compareTo(BigDecimal.ZERO) <= 0) {
log.warn("收款流水同步跳过:认领公告[{}]收款金额为空或小于0", announce.getBillno());
skippedCount++;
// log.warn("收款流水同步跳过:收款单[{}]收款金额(actrecamt)为空或小于0", recBill.getBillno());
state.skippedCount++;
continue;
}
validAnnounces.put(detailSerialNo, announce);
}
log.info("收款流水数据校验完成:有效 {} 条,跳过 {} 条", validAnnounces.size(), skippedCount);
if (validAnnounces.isEmpty()) {
log.info("收款流水同步无有效数据,记录操作日志后返回");
record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO,
"金蝶认领公告无有效数据");
return 0;
}
// 批量查询已存在的记录(FOR UPDATE 防并发)
log.info("收款流水同步开始批量查询已存在记录,查询条件:detailSerialNo IN {} 条", validAnnounces.size());
List<KingdeeReceiptFlow> existingEntities = baseMapper.selectList(
Wrappers.<KingdeeReceiptFlow>lambdaQuery()
.in(KingdeeReceiptFlow::getDetailSerialNo, validAnnounces.keySet())
.last("FOR UPDATE")
);
Map<String, KingdeeReceiptFlow> existingMap = existingEntities.stream()
.collect(Collectors.toMap(KingdeeReceiptFlow::getDetailSerialNo, e -> e));
log.info("收款流水批量查询完成:已存在 {} 条,待新增 {} 条",
existingEntities.size(), validAnnounces.size() - existingEntities.size());
// 第二遍:构建新增和更新列表
log.info("收款流水同步开始构建新增/更新列表");
List<KingdeeReceiptFlow> toInsert = new ArrayList<>();
List<KingdeeReceiptFlow> toUpdate = new ArrayList<>();
int createdCount = 0;
int updatedCount = 0;
for (Map.Entry<String, BfiClaimAnnounceVO> entry : validAnnounces.entrySet()) {
String detailSerialNo = entry.getKey();
BfiClaimAnnounceVO announce = entry.getValue();
BigDecimal receiptAmount = money(announce.getReamount());
// 判断是新增还是更新
KingdeeReceiptFlow existing = existingMap.get(detailSerialNo);
boolean created = (existing == null);
KingdeeReceiptFlow entity;
if (created) {
// 新增:初始化本地认领金额和状态
entity = new KingdeeReceiptFlow();
entity.setClaimedAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
entity.setClaimStatus(UNCLAIMED);
} else {
// 更新:保留本地已认领金额和状态
entity = existing;
}
// 复制公告数据到实体(不覆盖本地认领字段)
copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount);
// 根据本地累计认领金额与可认领上限重新计算认领状态
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
claimCeiling(entity)));
if (created) {
toInsert.add(entity);
createdCount++;
} else {
toUpdate.add(entity);
updatedCount++;
state.pending.add(new BfiRecBillRow(recBill, detailSerialNo, receiptAmount));
if (state.pending.size() >= SYNC_BATCH_SIZE) {
state.batchNo++;
state.totalCount += this.syncBatch(state.batchNo, state.pending);
state.pending.clear();
state.batchSerials.clear();
}
}
log.info("收款流水列表构建完成:待新增 {} 条,待更新 {} 条", createdCount, updatedCount);
}
// 批量插入和更新
if (!toInsert.isEmpty()) {
log.info("收款流水同步开始批量插入 {} 条", toInsert.size());
saveBatch(toInsert);
log.info("收款流水批量插入完成");
}
if (!toUpdate.isEmpty()) {
log.info("收款流水同步开始批量更新 {} 条", toUpdate.size());
updateBatchById(toUpdate);
log.info("收款流水批量更新完成");
}
/**
* 单批入库(独立事务):批内 FOR UPDATE 防并发,按明细流水号 upsert,操作日志批量落库
*
* @param batchNo 批次号(日志用)
* @param rows 本批流水行
* @return 本批新增+更新条数
*/
private int syncBatch(int batchNo, List<BfiRecBillRow> rows) {
Integer count = transactionTemplate.execute(status -> {
// 批量查询已存在的记录(FOR UPDATE 防并发)
Map<String, KingdeeReceiptFlow> existingMap = baseMapper.selectList(
Wrappers.<KingdeeReceiptFlow>lambdaQuery()
.in(KingdeeReceiptFlow::getDetailSerialNo,
rows.stream().map(BfiRecBillRow::detailSerialNo).toList())
.last("FOR UPDATE"))
.stream().collect(Collectors.toMap(KingdeeReceiptFlow::getDetailSerialNo, e -> e));
List<KingdeeReceiptFlow> toInsert = new ArrayList<>();
List<KingdeeReceiptFlow> toUpdate = new ArrayList<>();
for (BfiRecBillRow row : rows) {
KingdeeReceiptFlow existing = existingMap.get(row.detailSerialNo());
boolean created = (existing == null);
KingdeeReceiptFlow entity;
if (created) {
// 新增:初始化本地认领金额和状态
entity = new KingdeeReceiptFlow();
entity.setClaimedAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
entity.setClaimStatus(UNCLAIMED);
} else {
// 更新:保留本地已认领金额和状态
entity = existing;
}
// 复制收款单数据到实体(不覆盖本地认领字段)
copyRecBillRow(entity, row);
// 根据本地累计认领金额与可认领上限重新计算认领状态
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
claimCeiling(entity)));
if (created) {
toInsert.add(entity);
} else {
toUpdate.add(entity);
}
}
if (!toInsert.isEmpty()) {
saveBatch(toInsert);
}
if (!toUpdate.isEmpty()) {
updateBatchById(toUpdate);
}
// 记录操作日志(批量插入,避免逐行 N+1)
List<ReceiptFlowRecord> records = new ArrayList<>(toInsert.size() + toUpdate.size());
for (KingdeeReceiptFlow entity : toInsert) {
records.add(buildRecord(entity.getId(), null, "sync", "新增金蝶收款流水",
entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo()));
}
for (KingdeeReceiptFlow entity : toUpdate) {
records.add(buildRecord(entity.getId(), null, "sync", "更新金蝶收款流水",
entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo()));
}
if (!records.isEmpty()) {
Db.saveBatch(records);
}
return toInsert.size() + toUpdate.size();
});
log.info("收款流水同步第 {} 批入库完成,本批 {} 条", batchNo, count == null ? 0 : count);
return count == null ? 0 : count;
}
// 记录操作日志(批量插入,避免逐行 N+1)
log.info("收款流水同步开始记录操作日志,共 {} 条", toInsert.size() + toUpdate.size());
List<ReceiptFlowRecord> records = new ArrayList<>(toInsert.size() + toUpdate.size());
for (KingdeeReceiptFlow entity : toInsert) {
records.add(buildRecord(entity.getId(), null, "sync", "新增金蝶收款流水",
entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo()));
}
for (KingdeeReceiptFlow entity : toUpdate) {
records.add(buildRecord(entity.getId(), null, "sync", "更新金蝶收款流水",
entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo()));
}
if (!records.isEmpty()) {
Db.saveBatch(records);
}
log.info("收款流水操作日志记录完成");
/**
* A301-1 收款单查询返回的收款单 + 流水分录展开行(同步过程传递用)
*/
private record BfiRecBillRow(BfiRecBillVO recBill,
String detailSerialNo, BigDecimal receiptAmount) {
}
log.info("金蝶收款流水同步完成 claimusers:{},拉取 {} 条,新增 {} 条,更新 {} 条",
claimusers, announces.size(), createdCount, updatedCount);
return createdCount + updatedCount;
/**
* 收款流水整轮同步状态:待入库缓冲与统计累加(各切片共享,结束进日志摘要)
*/
private static final class RecBillSyncState {
/**
* 待入库缓冲:累计满 SYNC_BATCH_SIZE 即入库一批并清空
*/
final List<BfiRecBillRow> pending = new ArrayList<>();
/**
* 批内收款单号去重(含切片边界重复拉取)
*/
final Set<String> batchSerials = new HashSet<>();
/**
* A301-1 原始拉取收款单张数
*/
int pullCount;
/**
* 跳过条数(缺单号/金额异常/重复拉取)
*/
int skippedCount;
/**
* 新增+更新条数
*/
int totalCount;
/**
* 已入库批次数
*/
int batchNo;
/**
* 实际处理的翻页总页数
*/
int pageCount;
/**
* 实际处理的切片数
*/
int sliceCount;
}
private Map<String, Object> candidateRow(FormalSettlement settlement, BigDecimal claimedAmount) {
@@ -724,19 +811,12 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
}
/**
* 可认领上限:公告未认领金额与收款金额取较小正值。
* 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。
* 可认领上限:收款金额(A301-1 actrecamt)。
* 认领只登记本地并推合同合规,与金蝶侧收款单匹配(核销)状态无关;
* 金蝶未匹配收款金额(unmatchamountrec)仅作参考展示,不参与额度控制。
*/
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
BigDecimal receipt = money(flow.getReceiptAmount());
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
return unclaimed.min(receipt);
return money(flow.getReceiptAmount());
}
private Long toLong(Object value) {
@@ -774,405 +854,72 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
}
/**
* 手动推送金蝶(A301-3 收款认领结果推送)
* <p>
* 仅认领完成且未推送过的流水可推送;推送前以当前用户姓名重查 A301-2 最新公告对账。
* 推送失败不抛异常跨事务边界(保证失败状态落库),原因随结果对象返回。
* A301-1 收款单 → 收款流水表字段拷贝(仅覆盖金蝶侧来源字段,不覆盖本地认领金额与状态)。
* 金蝶匹配/结算金额原样落库仅作参考,不参与认领额度控制。
*/
@Override
public ReceiptClaimPushResult pushClaimToKingdee(Long flowId) {
if (flowId == null) {
throw new ServiceException("收款流水ID不能为空");
}
KingdeeReceiptFlow flow = existing(flowId);
if (flow.getBillId() == null) {
return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水");
}
String claimusers = UserCache.getUserRealName(AuthUtil.getUserId());
if (Func.isEmpty(claimusers)) {
throw new ServiceException("当前用户无姓名,无法推送金蝶收款认领");
}
// 推送前对账:以当前用户身份重查 A301-2 拿最新公告(手动推送者须在公告认领范围内)
BfiClaimAnnounceVO announce = queryClaimAnnounces(claimusers).stream()
.filter(item -> flow.getBillId().equals(item.getId()))
.findFirst().orElse(null);
if (announce == null) {
return ReceiptClaimPushResult.fail("金蝶侧未查询到该认领公告,当前用户可能不在公告认领范围内,请重新同步后重试");
}
return pushClaimLocked(flow.getId(), announce);
}
/**
* 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。
* 多副本部署下由 single-flight 分布式锁保证同一轮全局最多一份在跑,已有轮次进行中则本轮跳过。
*
* @return 执行摘要
*/
@Override
public String pushClaimByTask() {
return kingdeeSyncLockSupport.runSingleFlight(RECEIPT_CLAIM_PUSH_LOCK_PREFIX, bfiProperties.getReceiptClaimPush().getLockLeaseSeconds(),
"定时推送收款认领", this::executeReceiptClaimPush);
}
/**
* 定时推送收款认领主体:按认领人姓名串行处理——
* 先重查该认领人的 A301-2 最新公告并推完其名下全部流水,再到下一个认领人。
* A301-2 的 claimusers 必填且为单个用户姓名,定时任务无登录人上下文,
* 故按认领人逐人查询公告,按公告id(bill_id)匹配流水。
* 单条流水推送失败独立消化,不影响同批其他流水。
*
* @return 执行摘要
*/
private String executeReceiptClaimPush() {
List<KingdeeReceiptFlow> candidates = list(Wrappers.<KingdeeReceiptFlow>lambdaQuery()
.isNotNull(KingdeeReceiptFlow::getBillId)
.in(KingdeeReceiptFlow::getPushStatus, List.of(
KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_NONE, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED))
.eq(KingdeeReceiptFlow::getClaimStatus, CLAIMED)
.eq(KingdeeReceiptFlow::getStatus, 1)
.orderByAsc(KingdeeReceiptFlow::getId));
if (candidates.isEmpty()) {
return "无待推送流水";
}
Map<Long, KingdeeReceiptFlow> candidateById = candidates.stream()
.collect(Collectors.toMap(KingdeeReceiptFlow::getId, Function.identity(),
(first, second) -> first, LinkedHashMap::new));
// 认领人 → 其有效认领的流水id
Map<String, Set<Long>> claimedFlowIdsByClaimer = claimMapper.selectList(
Wrappers.<ReceiptClaim>lambdaQuery()
.in(ReceiptClaim::getReceiptFlowId, candidateById.keySet())
.eq(ReceiptClaim::getClaimStatus, CLAIMED)).stream()
.filter(claim -> Func.isNotEmpty(claim.getClaimerName()) && claim.getReceiptFlowId() != null)
.collect(Collectors.groupingBy(ReceiptClaim::getClaimerName,
Collectors.mapping(ReceiptClaim::getReceiptFlowId, Collectors.toSet())));
if (claimedFlowIdsByClaimer.isEmpty()) {
return "待推送流水 " + candidates.size() + " 条,但无有效认领人,跳过";
}
int successCount = 0;
int failedCount = 0;
int skippedCount = 0;
// 本轮已推送成功的流水:同一流水被多个认领人认领时,只在首个覆盖它的认领人处推送一次
Set<Long> pushedFlowIds = new HashSet<>();
// 按认领人逐人串行:查该认领人公告 → 推完其名下流水 → 下一个认领人
for (String claimerName : claimedFlowIdsByClaimer.keySet().stream().sorted().toList()) {
Set<Long> claimerFlowIds = claimedFlowIdsByClaimer.get(claimerName);
List<KingdeeReceiptFlow> claimerFlows = candidateById.values().stream()
.filter(flow -> claimerFlowIds.contains(flow.getId()))
.filter(flow -> !pushedFlowIds.contains(flow.getId()))
.toList();
if (claimerFlows.isEmpty()) {
continue;
}
// 该认领人的公告一次查询,按公告id归集
Map<Long, BfiClaimAnnounceVO> announceByBillId = new HashMap<>();
try {
for (BfiClaimAnnounceVO announce : queryClaimAnnounces(claimerName)) {
if (announce.getId() != null) {
announceByBillId.putIfAbsent(announce.getId(), announce);
}
}
} catch (Exception exception) {
log.error("定时推送收款认领:认领人[{}]公告查询失败,其名下流水全部跳过", claimerName, exception);
skippedCount += claimerFlows.size();
continue;
}
for (KingdeeReceiptFlow flow : claimerFlows) {
BfiClaimAnnounceVO announce = announceByBillId.get(flow.getBillId());
if (announce == null) {
skippedCount++;
log.info("定时推送收款认领:认领人[{}]流水[{}]未在公告查询结果中命中,跳过",
claimerName, flow.getId());
continue;
}
try {
ReceiptClaimPushResult result = pushClaimLocked(flow.getId(), announce);
if (result.isSuccess()) {
successCount++;
pushedFlowIds.add(flow.getId());
} else {
failedCount++;
}
} catch (Exception exception) {
failedCount++;
log.error("定时推送收款认领:认领人[{}]流水[{}]推送异常", claimerName, flow.getId(), exception);
}
}
}
String summary = "扫描 " + candidates.size() + " 条,推送成功 " + successCount
+ " 条,推送失败 " + failedCount + " 条,公告未命中跳过 " + skippedCount + " 条";
log.info("定时推送收款认领完成:{}", summary);
return summary;
}
/**
* 事务内推送:流水行锁覆盖闸门校验、对账、推送与状态落库全过程,
* 防定时任务与手动按钮并发对同一流水双推;
* 推送成功后触发合同合规认领链推送(C103-9/C107-1/C108-1,事务提交后执行,失败不影响金蝶推送结果)
*/
private ReceiptClaimPushResult pushClaimLocked(Long flowId, BfiClaimAnnounceVO announce) {
ReceiptClaimPushResult result = transactionTemplate.execute(status -> doPushClaimToKingdee(flowId, announce));
if (result != null && result.isSuccess()) {
hcsClaimSyncSupport.pushClaimToHcs(flowId);
}
return result;
}
/**
* 推送单条流水:静态闸门 → 最新公告对账 → 按流水归集分摊行推送 → 成败状态落库
*/
private ReceiptClaimPushResult doPushClaimToKingdee(Long flowId, BfiClaimAnnounceVO announce) {
KingdeeReceiptFlow flow = lockedFlow(flowId);
if (flow.getBillId() == null) {
return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水");
}
// 终态闸门:已推送流水不允许二次推送(作废重认领使等式再满足也不推,金蝶侧人工处置)
if (KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED.equals(flow.getPushStatus())) {
return ReceiptClaimPushResult.fail("该流水已推送金蝶,不允许重复推送");
}
// 推送前对账:以最新公告刷新公告侧未认领金额并重算认领状态(快照落库,页面口径同步修正)
flow.setUnclaimedAmount(money(announce.getUnclaimamount()));
flow.setKingdeeClaimStatus(trimToNull(announce.getClaimstatus()));
flow.setClaimMe(announce.getClaimme());
BigDecimal claimedTotal = flowClaimedAmount(flow.getId());
flow.setClaimedAmount(claimedTotal);
BigDecimal claimCeiling = claimCeiling(flow);
String claimStatus = amountClaimStatus(claimedTotal, claimCeiling);
flow.setClaimStatus(claimStatus);
updateById(flow);
if (!CLAIMED.equals(claimStatus)) {
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与可认领上限("
+ claimCeiling + ")不一致,不满足推送条件");
}
// 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集)
List<ReceiptClaimSettlement> allocations = claimSettlementMapper.selectList(
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId())
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
.orderByAsc(ReceiptClaimSettlement::getFormalSettlementId));
if (allocations.isEmpty()) {
return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送");
}
// 核心单据编号取第一张结算单同步金蝶回写的星瀚内码(应收/应付单金蝶id),TMS本地id金蝶无法识别
FormalSettlement firstSettlement = formalSettlementMapper.selectById(allocations.get(0).getFormalSettlementId());
if (firstSettlement == null || Func.isBlank(firstSettlement.getKingdeeBillId())) {
return ReceiptClaimPushResult.fail("结算单" + allocations.get(0).getFormalSettlementNo()
+ "未同步金蝶(缺少金蝶单据id),不满足推送条件");
}
// 推送来源编码:分摊结算单号下划线拼接(A301-3 契约口径;分摊不变则重试编码不变,
// 金蝶按 awrp_src_sys + awrp_src_code 幂等防重试双单)
String awrpSrcCode = allocations.stream()
.map(ReceiptClaimSettlement::getFormalSettlementNo)
.filter(Func::isNotEmpty)
.distinct()
.collect(Collectors.joining("_"));
flow.setAwrpSrcCode(awrpSrcCode);
BfiClaimPushDTO param = buildClaimPushDTO(flow, claimedTotal, allocations, firstSettlement.getKingdeeBillId());
log.info("调用BFI接口 收款认领结果推送开始 flowId:{},billId:{},awrpSrcCode:{},分摊明细 {} 行,认领金额 {}",
flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), allocations.size(), claimedTotal);
try {
BfiBillSaveResultVO result = bfiService.pushClaimResult(param);
return applyClaimPushSuccess(flow, extractClaimPushBillNo(result));
} catch (Exception exception) {
return applyClaimPushFailure(flow, exception);
}
}
/**
* A301-3 推送报文组装:billid=公告id,单条分录、金额=全部未推送金额(一笔流水一张金蝶收款单),
* 核心单据编号=第一张结算单的金蝶单据id(星瀚内码)
*/
private BfiClaimPushDTO buildClaimPushDTO(KingdeeReceiptFlow flow, BigDecimal claimedTotal,
List<ReceiptClaimSettlement> allocations, String coreBillId) {
BfiClaimPushDTO param = new BfiClaimPushDTO();
// 【必填】认领公告id
param.setBillid(flow.getBillId());
// 【必填】来源系统编码
param.setAwrpSrcSys(bfiProperties.getSrcSys());
// 【必填】来源系统单据编码(分摊结算单号下划线拼接)
param.setAwrpSrcCode(flow.getAwrpSrcCode());
// 【必填】收款类型.编码
param.setRecpaytypeNumber(KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE);
// 【必填】付款人类型
param.setPaymenttype(KingdeeBfiConstant.BFI_CLAIM_PAYMENT_TYPE);
// 【必填】付款人名称
param.setRecviewpayer(flow.getPayerName());
// 【必填】付款人id
param.setRecpayer(flow.getPayerId());
// 【必填】单据体分录:仅一条,应收金额=全部未推送金额(累计有效认领)
BfiClaimPushEntryDTO entry = new BfiClaimPushEntryDTO();
// 【必填】应收金额(全部未推送金额)
entry.setEReceivableamt(claimedTotal);
// 分录行序号(第一张结算单id,分摊已按结算单id升序,多张取最小)
entry.setSeq(allocations.get(0).getFormalSettlementId());
// 核心单据类型
// entry.setECorebilltype(KingdeeBfiConstant.BFI_CLAIM_CORE_BILL_TYPE);
// 核心单据编号(第一张结算单的金蝶单据id,星瀚内码)
entry.setECorebillno(coreBillId);
param.setEntryentity(List.of(entry));
return param;
}
/**
* 解析 A301-3 推送结果:全项成功返回单据号,否则抛出汇总错误文本(由失败路径落库)。
* 成功响应的 number 为空,金蝶收款单号取回执 keys 中的 awrp_src_code(来源编码)。
*/
private String extractClaimPushBillNo(BfiBillSaveResultVO result) {
if (result == null || result.getResult() == null || result.getResult().isEmpty()) {
throw new ServiceException("金蝶认领结果推送失败,返回结果为空");
}
List<BfiBillSaveResultItemVO> items = result.getResult();
boolean allSuccess = items.stream().allMatch(item -> Boolean.TRUE.equals(item.getBillStatus()));
if (!allSuccess) {
String errorText = items.stream()
.map(BfiBillSaveResultItemVO::getErrorText)
.filter(text -> !text.isEmpty())
.collect(Collectors.joining(";"));
throw new ServiceException("金蝶认领结果推送失败"
+ (errorText.isEmpty() ? "" : ":" + errorText));
}
return items.stream()
.map(this::claimPushBillNoOf)
.filter(Func::isNotEmpty)
.findFirst().orElse(null);
}
/**
* 单项推送结果 → 单据号:A301-3 成功响应 number 为空,取回执键值中的 awrp_src_code
*/
private String claimPushBillNoOf(BfiBillSaveResultItemVO item) {
if (Func.isNotEmpty(item.getNumber())) {
return item.getNumber();
}
return item.getKeys() == null ? null : item.getKeys().get("awrp_src_code");
}
/**
* 推送成功:流水置已推送并回填金蝶收款单号,级联刷新名下未作废认领主单的冗余展示字段
*/
private ReceiptClaimPushResult applyClaimPushSuccess(KingdeeReceiptFlow flow, String kingdeeBillNo) {
log.info("调用BFI接口 收款认领结果推送成功 flowId:{},金蝶收款单号:{}",
flow.getId(), Func.isEmpty(kingdeeBillNo) ? "响应未返回" : kingdeeBillNo);
flow.setPushStatus(KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED);
flow.setPushedTime(LocalDateTime.now());
flow.setKingdeeBillNo(kingdeeBillNo);
flow.setKingdeeBillStatus(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED);
// 失败原因需显式置空(实体 update 忽略 null 字段,走 lambdaUpdate)
this.update(Wrappers.<KingdeeReceiptFlow>lambdaUpdate()
.eq(KingdeeReceiptFlow::getId, flow.getId())
.set(KingdeeReceiptFlow::getPushStatus, flow.getPushStatus())
.set(KingdeeReceiptFlow::getPushFailReason, null)
.set(KingdeeReceiptFlow::getPushedTime, flow.getPushedTime())
.set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode())
.set(KingdeeReceiptFlow::getKingdeeBillNo, kingdeeBillNo)
.set(KingdeeReceiptFlow::getKingdeeBillStatus, flow.getKingdeeBillStatus()));
// 级联刷新名下未作废认领主单的冗余展示字段
claimMapper.update(null, Wrappers.<ReceiptClaim>lambdaUpdate()
.eq(ReceiptClaim::getReceiptFlowId, flow.getId())
.eq(ReceiptClaim::getClaimStatus, CLAIMED)
.set(ReceiptClaim::getKingdeeBillNo, kingdeeBillNo)
.set(ReceiptClaim::getKingdeeBillStatus, KingdeeBfiConstant.KINGDEE_STATUS_PUSHED));
record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(),
"金蝶收款单号:" + (Func.isEmpty(kingdeeBillNo) ? "响应未返回" : kingdeeBillNo));
return ReceiptClaimPushResult.success(kingdeeBillNo);
}
/**
* 推送失败:流水置推送失败并记录原因,本地认领数据保留,可重新推送(重试复用同一来源编码)
*/
private ReceiptClaimPushResult applyClaimPushFailure(KingdeeReceiptFlow flow, Exception exception) {
String reason = Func.isEmpty(exception.getMessage())
? exception.getClass().getSimpleName() : exception.getMessage();
reason = truncateText(reason, 1000);
log.error("调用BFI接口 收款认领结果推送失败 flowId:{},billId:{},awrpSrcCode:{}",
flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), exception);
this.update(Wrappers.<KingdeeReceiptFlow>lambdaUpdate()
.eq(KingdeeReceiptFlow::getId, flow.getId())
.set(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED)
.set(KingdeeReceiptFlow::getPushFailReason, reason)
.set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode()));
record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(),
"推送失败:" + truncateText(reason, 200));
return ReceiptClaimPushResult.fail("金蝶认领结果推送失败:" + reason);
}
/**
* A301-2 收款认领公告查询:按认领人姓名全量翻页,时间窗口取 Nacos 配置(默认近半年)。
* 认领公告同步、手动推送对账与定时任务对账共用。
*/
private List<BfiClaimAnnounceVO> queryClaimAnnounces(String claimusers) {
int months = bfiProperties.getClaimAnnounce().getMonths();
// 认领人直接取 Nacos 配置值
String effectiveClaimusers = bfiProperties.getClaimAnnounce().getClaimusers();
LocalDateTime endDate = LocalDateTime.now();
LocalDateTime startDate = endDate.minusMonths(months);
long start = System.currentTimeMillis();
log.info("调用BFI接口 收款认领公告查询开始 claimusers:{},时间窗口:{} ~ {}(近{}个月)",
effectiveClaimusers, startDate.format(BFI_DATE_TIME_FORMATTER), endDate.format(BFI_DATE_TIME_FORMATTER), months);
BfiClaimAnnounceQueryDTO queryDTO = new BfiClaimAnnounceQueryDTO();
queryDTO.setClaimusers(effectiveClaimusers);
queryDTO.setModifytimeS(startDate.format(BFI_DATE_TIME_FORMATTER));
queryDTO.setModifytimeE(endDate.format(BFI_DATE_TIME_FORMATTER));
queryDTO.setPageSize(SYNC_PAGE_SIZE);
List<BfiClaimAnnounceVO> announces = BfiUtil.queryAllPages(pageNo -> {
queryDTO.setPageNo(pageNo);
return bfiService.queryClaimAnnounce(queryDTO);
});
log.info("调用BFI接口 收款认领公告查询结束 claimusers:{},耗时:{}ms,拉取 {} 条",
effectiveClaimusers, System.currentTimeMillis() - start, announces.size());
return announces;
}
/**
* A301-2 认领公告 → 收款流水表字段拷贝(仅覆盖金蝶侧来源字段,不覆盖本地认领金额与状态)
*/
private void copyAnnounceRow(KingdeeReceiptFlow target, BfiClaimAnnounceVO source,
String detailSerialNo, BigDecimal receiptAmount) {
// 金蝶认领公告id
target.setBillId(source.getId());
// 收款通知单号
private void copyRecBillRow(KingdeeReceiptFlow target, BfiRecBillRow row) {
BfiRecBillVO source = row.recBill();
// 金蝶收款单号(既是收款通知单号也是金蝶单据号)
target.setReceiptNoticeNo(trimToEmpty(source.getBillno()));
// 付款人名称(取自对方户名)
target.setPayerName(trimToEmpty(source.getOppunit()));
// 付款人id(取自对方账号)
target.setPayerId(trimToEmpty(source.getOppbanknumber()));
// 收款人
target.setPayeeName(trimToNull(source.getRecpayee()));
// 收款金额
target.setReceiptAmount(receiptAmount);
// 对方单位名称
target.setCounterpartyName(trimToEmpty(source.getOppunit()));
// 对方银行账号
target.setCounterpartyAccount(trimToEmpty(source.getOppbanknumber()));
// 对方开户行
target.setCounterpartyBank(trimToEmpty(source.getOppbank()));
target.setKingdeeBillNo(trimToNull(source.getBillno()));
// 金蝶回写状态随单据状态同步刷新
target.setKingdeeBillStatus(billStatusOf(source.getBillstatus()));
// 付款人名称
target.setPayerName(trimToEmpty(source.getPayername()));
// 收款人(收款公司名称)
target.setPayeeName(trimToNull(source.getOrgName()));
// 收款人(公司).编码
target.setOrgNumber(trimToNull(source.getOrgNumber()));
// 核算组织.编码
target.setOpenOrgNumber(trimToNull(source.getOpenorgNumber()));
// 收款金额(主单 actrecamt)
target.setReceiptAmount(row.receiptAmount());
// 对方单位名称(取付款人名称)
target.setCounterpartyName(trimToEmpty(source.getPayername()));
// 对方银行账号(付款账户)
target.setCounterpartyAccount(trimToEmpty(source.getPayeracctbanknum()));
// 对方开户行(付款银行)
target.setCounterpartyBank(trimToEmpty(source.getPayerbankname()));
// 摘要
target.setSummary(trimToNull(source.getDescription()));
// 交易日期
target.setTransactionTime(parseDateTime(source.getTradetime()));
target.setSummary(trimToNull(source.getTxtDescription()));
// 交易日期(业务日期)
target.setTransactionTime(parseDateTime(source.getBizdate()));
// 明细流水号
target.setDetailSerialNo(detailSerialNo);
// 未认领金额
target.setUnclaimedAmount(money(source.getUnclaimamount()));
target.setDetailSerialNo(row.detailSerialNo());
// 未匹配收款金额(金蝶参考展示,不参与认领额度)
target.setUnmatchAmountRec(money(source.getUnmatchamountrec()));
// 已匹配收款金额 / 已匹配付款金额 / 未匹配付款金额
target.setMatchAmountRec(money(source.getMatchamountrec()));
target.setMatchAmountPay(money(source.getMatchamountpay()));
target.setUnmatchAmountPay(money(source.getUnmatchamountpay()));
// 已结算金额 / 未结算金额
target.setSettleAmount(money(source.getSettleamount()));
target.setUnsettleAmount(money(source.getUnsettleamount()));
// 手续费
target.setFee(source.getFee() == null ? null : source.getFee().setScale(2, RoundingMode.HALF_UP));
// 公告认领状态
target.setKingdeeClaimStatus(trimToNull(source.getClaimstatus()));
// 我已认领
target.setClaimMe(source.getClaimme());
// 来源更新时间
target.setSourceUpdatedTime(target.getTransactionTime() == null
? LocalDateTime.now() : target.getTransactionTime());
target.setSourceUpdatedTime(LocalDateTime.now());
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (Func.isNotEmpty(value)) {
return value.trim();
}
/**
* A301-1 单据状态 → 本地金蝶回写状态(同步时随单据刷新)
*
* @param billStatus 金蝶 billstatus(A暂存/B已提交/C已审核/D已收款/G已退单)
* @return 本地状态
*/
private String billStatusOf(String billStatus) {
if ("D".equals(billStatus)) {
return KingdeeBfiConstant.KINGDEE_STATUS_RECEIVED;
}
return null;
if ("C".equals(billStatus)) {
return APPROVED;
}
if ("A".equals(billStatus) || "B".equals(billStatus)) {
return KingdeeBfiConstant.KINGDEE_STATUS_PUSHED;
}
// 其余状态(含 G 已退单/E 变更中)按处理失败记录,提示人工在金蝶核查
log.warn("金蝶收款单单据状态[{}]未匹配映射,同步按 failed 处理", billStatus);
return KingdeeBfiConstant.KINGDEE_STATUS_FAILED;
}
private LocalDateTime parseDateTime(String value) {
@@ -1237,13 +984,6 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
}
}
private String truncateText(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength);
}
private void record(Long flowId, Long claimId, String actionType, String actionName,
String fromStatus, String toStatus, BigDecimal operationAmount, String content) {
recordMapper.insert(buildRecord(flowId, claimId, actionType, actionName,
@@ -11,8 +11,8 @@
* 2. Redistribution of this software's source code to any third party
* is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for their own commercial purposes.
* 3. Licensees may copyright their own code but cannot use segments of this software
* for their own commercial purposes.
* <p>
* Using this software signifies agreement to this license agreement and terms of use,
* and you expressly agree to be bound by the terms of use of this software.
@@ -33,17 +33,24 @@ import org.springblade.core.tool.utils.StringUtil;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.hcs.dto.HcsClaimRecordAddDTO;
import org.springblade.hcs.dto.HcsCollectionFlowVO;
import org.springblade.hcs.dto.HcsFormSaveVO;
import org.springblade.hcs.dto.HcsIdVO;
import org.springblade.hcs.dto.HcsMechanismsDTO;
import org.springblade.hcs.dto.HcsPageQueryDTO;
import org.springblade.hcs.dto.HcsPageVO;
import org.springblade.hcs.dto.HcsPerfRecordFormDataDTO;
import org.springblade.hcs.dto.HcsPerfRecordPropsDTO;
import org.springblade.hcs.dto.HcsPerfRecordSaveDTO;
import org.springblade.hcs.dto.HcsRefVO;
import org.springblade.hcs.dto.HcsResult;
import org.springblade.hcs.dto.HcsUserRefVO;
import org.springblade.hcs.exception.HcsException;
import org.springblade.hcs.feign.IHcsClient;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.KingdeeReceiptFlowMapper;
import org.springblade.transport.mapper.ReceiptClaimHcsRecordMapper;
import org.springblade.transport.mapper.ReceiptClaimMapper;
import org.springblade.transport.mapper.ReceiptClaimSettlementMapper;
import org.springblade.transport.pojo.entity.ContractManage;
@@ -51,12 +58,14 @@ import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.HcsContractExeStage;
import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
import org.springblade.transport.pojo.entity.ReceiptClaim;
import org.springblade.transport.pojo.entity.ReceiptClaimHcsRecord;
import org.springblade.transport.pojo.entity.ReceiptClaimSettlement;
import org.springblade.transport.service.IContractExeStageService;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.List;
@@ -67,13 +76,14 @@ import java.util.stream.Collectors;
/**
* HCS 收款认领链推送编排支撑组件(C103-9 更新履约计划 + C107-1 认领记录 + C108-1 履约流水)
* <p>
* 触发点:金蝶认领推送(A301-3)成功之后(手动按钮与定时任务共用钩子)。
* 触发点:收款流水认领事务提交后按认领主单推送(作废认领事务提交后推 C107-3 作废同步)。
* 开关复用既有配置(不单独新增):exe-stage.sync-enabled 门禁 C103-9/C107-1(与合同链履约计划同步同开关),
* perf-record.sync-enabled 门禁 C108-1(与付款链履约记录同步同开关)。
* 编排顺序:先 C103-9(认领金额累加到收款履约计划行并重推,C107-1/C108-1 引用其返回的阶段ID),
* 再按「认领主单 × 合同」推 C107-1(合规流水ID 经 C104-2 按明细流水号定位),最后按合同推 C108-1 履约流水。
* 再按「认领主单 × 合同」推 C107-1(合规流水ID 经 C104-2 按明细流水号定位,返回 fdId 落库
* blade_receipt_claim_hcs_record 供作废 C107-3 引用),最后按合同推 C108-1 履约流水。
* 合同未推送合规(hcs_fd_id 空)时仅累加计划行金额,随合同链路首推。
* 推送失败仅记日志不回滚金蝶推送结果(单合同失败不影响其余合同)。
* 推送失败仅落失败标记与日志,不回滚认领主流程(单合同失败不影响其余合同,不做定时重试)。
*
* @author Chill
*/
@@ -86,60 +96,80 @@ public class HcsClaimSyncSupport {
* 认领完成状态(与 ReceiptFlowServiceImpl.CLAIMED 同值)
*/
private static final String CLAIMED = "claimed";
/**
* 推送状态:推送成功
*/
private static final String PUSH_SUCCESS = "success";
/**
* 推送状态:推送失败(仅记标记,不做定时重试)
*/
private static final String PUSH_FAILED = "failed";
/**
* 推送状态:已作废(C107-3 同步成功)
*/
private static final String PUSH_VOIDED = "voided";
/**
* 认领记录状态:已认领
*/
private static final int CLAIM_STATUS_CLAIMED = 0;
/**
* 认领记录状态:已作废
*/
private static final int CLAIM_STATUS_VOIDED = 1;
private final IHcsClient hcsClient;
private final HcsProperties hcsProperties;
private final KingdeeReceiptFlowMapper receiptFlowMapper;
private final ReceiptClaimMapper claimMapper;
private final ReceiptClaimSettlementMapper claimSettlementMapper;
private final ReceiptClaimHcsRecordMapper claimHcsRecordMapper;
private final FormalSettlementMapper formalSettlementMapper;
private final ContractManageMapper contractManageMapper;
private final IContractExeStageService contractExeStageService;
private final HcsExeStageSyncSupport hcsExeStageSyncSupport;
private final HcsPerfRecordAddSupport hcsPerfRecordAddSupport;
/**
* 金蝶认领推送成功后推送合同合规(C103-9 → C107-1 → C108-1)
* 认领事务提交后推送合同合规(C103-9 → C107-1 → C108-1,按认领主单维度)
* <p>
* 复用既有开关:exe-stage.sync-enabled(履约计划同步)门禁 C103-9/C107-1,
* perf-record.sync-enabled(履约记录同步)门禁 C108-1;开关关闭或推送异常均静默返回,
* 不影响金蝶认领推送主流程与事务。
* 不影响认领主流程与事务。
*
* @param flowId 收款流水ID
* @param claimId 认领主单ID
*/
public void pushClaimToHcs(Long flowId) {
public void pushClaimToHcs(Long claimId) {
if (!hcsProperties.getExeStage().isSyncEnabled()) {
return;
}
try {
doPushClaimToHcs(flowId);
doPushClaimToHcs(claimId);
} catch (Exception exception) {
log.error("HCS收款认领链推送异常 flowId:{}", flowId, exception);
log.error("HCS收款认领链推送异常 claimId:{}", claimId, exception);
}
}
/**
* 推送主体:归集有效认领分摊(按合同分组)→ 逐合同推送(单合同失败不影响其余合同)
* 推送主体:归集本认领主单有效分摊(按合同分组)→ 逐合同推送(单合同失败不影响其余合同)
*/
private void doPushClaimToHcs(Long flowId) {
KingdeeReceiptFlow flow = receiptFlowMapper.selectById(flowId);
private void doPushClaimToHcs(Long claimId) {
ReceiptClaim claim = claimMapper.selectById(claimId);
if (claim == null || !CLAIMED.equals(claim.getClaimStatus())) {
log.info("HCS收款认领链推送跳过:认领主单不存在或已作废 claimId={}", claimId);
return;
}
KingdeeReceiptFlow flow = receiptFlowMapper.selectById(claim.getReceiptFlowId());
if (flow == null) {
log.warn("HCS收款认领链推送跳过:收款流水不存在 claimId={},flowId={}", claimId, claim.getReceiptFlowId());
return;
}
// 未作废认领主单(作废认领不同步合规)
List<ReceiptClaim> claims = claimMapper.selectList(Wrappers.<ReceiptClaim>lambdaQuery()
.eq(ReceiptClaim::getReceiptFlowId, flowId)
.eq(ReceiptClaim::getClaimStatus, CLAIMED));
if (claims.isEmpty()) {
return;
}
// 有效分摊行(作废时已置 status=0)
// 本认领主单有效分摊行(作废时已置 status=0)
List<ReceiptClaimSettlement> allocations = claimSettlementMapper.selectList(
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
.eq(ReceiptClaimSettlement::getReceiptClaimId, claimId)
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0));
if (allocations.isEmpty()) {
log.info("HCS收款认领链推送跳过:认领主单无有效分摊 claimId={}", claimId);
return;
}
// 结算单批量定位合同(一次 IN 查询,避免逐行 N+1)
@@ -147,20 +177,18 @@ public class HcsClaimSyncSupport {
allocations.stream().map(ReceiptClaimSettlement::getFormalSettlementId)
.filter(Objects::nonNull).distinct().toList()).stream()
.collect(Collectors.toMap(FormalSettlement::getId, item -> item, (first, second) -> first));
// 合同id → 认领主单id → 分摊金额
Map<Long, Map<Long, BigDecimal>> amountByContractByClaim = new LinkedHashMap<>();
// 合同id → 本认领主单分摊金额
Map<Long, BigDecimal> amountByContract = new LinkedHashMap<>();
for (ReceiptClaimSettlement allocation : allocations) {
FormalSettlement settlement = settlements.get(allocation.getFormalSettlementId());
if (settlement == null || settlement.getContractId() == null
|| allocation.getReceiptClaimId() == null || allocation.getAllocatedReceiptAmount() == null) {
|| allocation.getAllocatedReceiptAmount() == null) {
continue;
}
amountByContractByClaim
.computeIfAbsent(settlement.getContractId(), key -> new LinkedHashMap<>())
.merge(allocation.getReceiptClaimId(), allocation.getAllocatedReceiptAmount(), BigDecimal::add);
amountByContract.merge(settlement.getContractId(), allocation.getAllocatedReceiptAmount(), BigDecimal::add);
}
if (amountByContractByClaim.isEmpty()) {
log.info("HCS收款认领链推送跳过:分摊行未关联合同 flowId={}", flowId);
if (amountByContract.isEmpty()) {
log.info("HCS收款认领链推送跳过:分摊行未关联合同 claimId={},flowId={}", claimId, flow.getId());
return;
}
// 合规侧收款流水ID(C107-1 必填引用,一次定位全部合同共用;定位失败仅跳过 C107-1,不阻塞履约计划更新)
@@ -168,47 +196,47 @@ public class HcsClaimSyncSupport {
try {
hcsCollectionFlowId = locateHcsCollectionFlowId(flow);
} catch (Exception exception) {
log.error("HCS收款流水定位失败 flowId:{},跳过C107-1认领记录推送", flowId, exception);
log.error("HCS收款流水定位失败 flowId:{},跳过C107-1认领记录推送", flow.getId(), exception);
}
for (Map.Entry<Long, Map<Long, BigDecimal>> contractEntry : amountByContractByClaim.entrySet()) {
for (Map.Entry<Long, BigDecimal> contractEntry : amountByContract.entrySet()) {
try {
pushOneContract(flow, claims, contractEntry.getKey(), contractEntry.getValue(), hcsCollectionFlowId);
pushOneContract(flow, claim, contractEntry.getKey(), contractEntry.getValue(), hcsCollectionFlowId);
} catch (Exception exception) {
log.error("HCS收款认领链推送失败 flowId:{},contractId:{}", flowId, contractEntry.getKey(), exception);
log.error("HCS收款认领链推送失败 claimId:{},flowId:{},contractId:{}",
claimId, flow.getId(), contractEntry.getKey(), exception);
}
}
}
/**
* 推送单个合同:① C103-9 更新履约计划(认领金额累加重推)→ ② C107-1 认领记录(按认领主单分组)→ ③ C108-1 履约流水
* 推送单个合同:① C103-9 更新履约计划(认领金额累加重推)→ ② C107-1 认领记录(结果落库)→ ③ C108-1 履约流水
*/
private void pushOneContract(KingdeeReceiptFlow flow, List<ReceiptClaim> claims, Long contractId,
Map<Long, BigDecimal> amountByClaim, String hcsCollectionFlowId) {
private void pushOneContract(KingdeeReceiptFlow flow, ReceiptClaim claim, Long contractId,
BigDecimal claimAmount, String hcsCollectionFlowId) {
ContractManage contractManage = contractManageMapper.selectById(contractId);
if (contractManage == null) {
log.warn("HCS收款认领链推送跳过:合同不存在 contractId={}", contractId);
return;
}
BigDecimal claimedTotal = amountByClaim.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
// ① C103-9 更新合同履约计划:认领金额累加到收款计划行;已同步行置 update 凭阶段ID 重推
contractExeStageService.accumulateClaimedAmountOnPush(contractId, claimedTotal);
contractExeStageService.accumulateClaimedAmountOnPush(contractId, claimAmount);
HcsContractExeStage stageRow = contractExeStageService.findCollectionStageRow(contractId, HcsSyncConstant.FUND_NATURE_PROGRESS);
if (StringUtil.isNotBlank(contractManage.getHcsFdId())) {
// 行级重推(无待同步行返回提示,不视为失败);合同未推送合规时随合同链路首推
hcsExeStageSyncSupport.syncExeStages(contractManage);
}
// ② C107-1 认领记录:按「认领主单 × 合同」推送,金额为该认领主单分摊到本合同的金额
// ② C107-1 认领记录:本认领主单分摊到本合同的金额,返回 fdId 落库供 C107-3 作废引用
if (StringUtil.isBlank(hcsCollectionFlowId)) {
log.warn("HCS认领记录推送跳过:未定位到合规收款流水 flowId={},detailSerialNo={}", flow.getId(), flow.getDetailSerialNo());
log.warn("HCS认领记录推送跳过:未定位到合规收款流水 claimId={},flowId={},detailSerialNo={}",
claim.getId(), flow.getId(), flow.getDetailSerialNo());
saveClaimHcsRecord(claim, flow, contractManage, claimAmount, null, PUSH_FAILED, "C104-2未定位到合规收款流水");
} else {
Map<Long, ReceiptClaim> claimsById = claims.stream()
.collect(Collectors.toMap(ReceiptClaim::getId, item -> item, (first, second) -> first));
for (Map.Entry<Long, BigDecimal> claimEntry : amountByClaim.entrySet()) {
pushClaimRecord(flow, claimsById.get(claimEntry.getKey()), contractManage, stageRow, claimEntry.getValue(), hcsCollectionFlowId);
}
String claimRecordFdId = pushClaimRecord(flow, claim, contractManage, stageRow, claimAmount, hcsCollectionFlowId);
saveClaimHcsRecord(claim, flow, contractManage, claimAmount, claimRecordFdId,
StringUtil.isBlank(claimRecordFdId) ? PUSH_FAILED : PUSH_SUCCESS, null);
}
// ③ C108-1 履约流水:按合同一条(开关 perf-record.sync-enabled,与付款链履约记录同步同开关),
// 须已推送合规且存在已同步收款计划行(fd_plan_id 必填);履约日期为收款流水交易日期
// 须已推送合规且存在已同步收款计划行(fd_plan_id 必填)
if (!hcsProperties.getPerfRecord().isSyncEnabled()) {
log.info("HCS履约流水推送跳过:履约记录同步开关未开启 contractNo={}", contractManage.getContractNo());
return;
@@ -221,16 +249,18 @@ public class HcsClaimSyncSupport {
log.info("HCS履约流水推送跳过:无已同步收款履约计划行 contractNo={}", contractManage.getContractNo());
return;
}
pushPerfRecord(flow, contractManage, stageRow, claimedTotal);
pushPerfRecord(flow, contractManage, stageRow, claimAmount, claim.getClaimDate());
}
/**
* C107-1 认领记录推送(单认领主单 × 单合同)
*
* @return 合规侧认领记录 fdId(响应未返回返回 null)
*/
private void pushClaimRecord(KingdeeReceiptFlow flow, ReceiptClaim claim, ContractManage contractManage,
HcsContractExeStage stageRow, BigDecimal claimAmount, String hcsCollectionFlowId) {
if (claim == null || claimAmount == null || claimAmount.signum() <= 0) {
return;
private String pushClaimRecord(KingdeeReceiptFlow flow, ReceiptClaim claim, ContractManage contractManage,
HcsContractExeStage stageRow, BigDecimal claimAmount, String hcsCollectionFlowId) {
if (claimAmount == null || claimAmount.signum() <= 0) {
return null;
}
HcsClaimRecordAddDTO param = new HcsClaimRecordAddDTO();
// 【必填】收款流水引用(合规侧流水ID,C104-2 按明细流水号定位)
@@ -259,7 +289,7 @@ public class HcsClaimSyncSupport {
param.setFdClaimDate(toMillis(claim.getClaimDate()));
param.setFdRemark(claim.getRemark());
// 状态 0=已认领(仅推送未作废认领)
param.setFdStatus(0);
param.setFdStatus(CLAIM_STATUS_CLAIMED);
log.info("HCS认领记录推送开始 flowId:{},claimId:{},contractNo:{},认领金额 {}",
flow.getId(), claim.getId(), contractManage.getContractNo(), claimAmount);
HcsResult<HcsIdVO> result = hcsClient.claimRecordAdd(param);
@@ -267,25 +297,137 @@ public class HcsClaimSyncSupport {
log.error("HCS认领记录推送失败响应:{},claimId={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), claim.getId());
throw new HcsException("C107-1 认领记录推送失败:" + (result == null ? "响应为空" : result.getMsg()));
}
// shortcut: C107-1 返回 fdId 未持久化(认领作废同步 C107-3 未实现,重推会重复);需作废联动时先落库
String claimRecordFdId = result.getData() == null ? null : result.getData().getFdId();
log.info("HCS认领记录推送成功 flowId:{},claimId:{},contractNo:{},claimRecordFdId:{}",
flow.getId(), claim.getId(), contractManage.getContractNo(),
result.getData() == null ? "响应未返回" : result.getData().getFdId());
StringUtil.isBlank(claimRecordFdId) ? "响应未返回" : claimRecordFdId);
return claimRecordFdId;
}
/**
* C108-1 履约流水推送(单合同一条:申请金额=实际金额=该合同累计认领金额,履约日期=收款流水交易日期)
* C107-1 推送结果落库(认领主单 × 合同一条):成功持久化合规 fdId,失败落标记与原因
*/
private void saveClaimHcsRecord(ReceiptClaim claim, KingdeeReceiptFlow flow, ContractManage contractManage,
BigDecimal claimAmount, String claimRecordFdId, String pushStatus, String failReason) {
ReceiptClaimHcsRecord record = new ReceiptClaimHcsRecord();
record.setReceiptClaimId(claim.getId());
record.setReceiptFlowId(flow.getId());
record.setContractId(contractManage.getId());
record.setContractNo(contractManage.getContractNo());
record.setClaimAmount(claimAmount);
record.setHcsClaimRecordFdId(claimRecordFdId);
record.setPushStatus(pushStatus);
record.setFailReason(failReason);
record.setPushedTime(PUSH_SUCCESS.equals(pushStatus) ? LocalDateTime.now() : null);
claimHcsRecordMapper.insert(record);
}
/**
* 认领作废事务提交后同步合规 C107-3(修改认领记录 fdStatus=1 已作废)
* <p>
* 仅同步本认领主单推送成功的合规记录;单条失败独立记日志,不影响其余合同。
* 作废失败时本地记录保持 success(合规侧仍为已认领),需人工在合规侧处置。
*
* @param claimId 认领主单ID
*/
public void voidClaimToHcs(Long claimId) {
if (!hcsProperties.getExeStage().isSyncEnabled()) {
return;
}
try {
List<ReceiptClaimHcsRecord> records = claimHcsRecordMapper.selectList(
Wrappers.<ReceiptClaimHcsRecord>lambdaQuery()
.eq(ReceiptClaimHcsRecord::getReceiptClaimId, claimId)
.eq(ReceiptClaimHcsRecord::getPushStatus, PUSH_SUCCESS));
if (records.isEmpty()) {
log.info("HCS认领作废同步跳过:无推送成功的合规认领记录 claimId={}", claimId);
return;
}
for (ReceiptClaimHcsRecord record : records) {
try {
voidOneClaimRecord(record);
} catch (Exception exception) {
log.error("HCS认领作废同步失败 claimId:{},recordId:{},hcsFdId:{}",
claimId, record.getId(), record.getHcsClaimRecordFdId(), exception);
}
}
} catch (Exception exception) {
log.error("HCS认领作废同步异常 claimId:{}", claimId, exception);
}
}
/**
* C107-3 单条作废:fdId 必填,fdStatus=1 已作废;成功后本地记录置 voided
*/
private void voidOneClaimRecord(ReceiptClaimHcsRecord record) {
HcsClaimRecordAddDTO param = new HcsClaimRecordAddDTO();
// 【必填】合规侧认领记录ID(C107-1 推送成功时落库)
param.setFdId(record.getHcsClaimRecordFdId());
// 状态 1=已作废
param.setFdStatus(CLAIM_STATUS_VOIDED);
log.info("HCS认领作废同步开始 claimId:{},contractNo:{},hcsFdId:{}",
record.getReceiptClaimId(), record.getContractNo(), record.getHcsClaimRecordFdId());
HcsResult<HcsIdVO> result = hcsClient.claimRecordUpdate(param);
if (result == null || !result.isSuccess()) {
throw new HcsException("C107-3 认领作废同步失败:" + (result == null ? "响应为空" : result.getMsg()));
}
// 本地推送记录置已作废
claimHcsRecordMapper.update(null, Wrappers.<ReceiptClaimHcsRecord>lambdaUpdate()
.eq(ReceiptClaimHcsRecord::getId, record.getId())
.set(ReceiptClaimHcsRecord::getPushStatus, PUSH_VOIDED));
log.info("HCS认领作废同步成功 claimId:{},contractNo:{},hcsFdId:{}",
record.getReceiptClaimId(), record.getContractNo(), record.getHcsClaimRecordFdId());
}
/**
* C108-1 履约流水推送(单合同一条:申请金额=实际金额=本次认领分摊金额)
*/
private void pushPerfRecord(KingdeeReceiptFlow flow, ContractManage contractManage,
HcsContractExeStage stageRow, BigDecimal claimedTotal) {
// 履约日期:交易日期(需求口径),交易时间为空兜底当天
LocalDate perfDate = flow.getTransactionTime() != null ? flow.getTransactionTime().toLocalDate() : LocalDate.now();
String perfFdId = hcsPerfRecordAddSupport.pushPerfRecordAdd(contractManage, stageRow,
firstNonBlank(flow.getReceiptNoticeNo(), flow.getDetailSerialNo()), claimedTotal, toMillis(perfDate),
HcsSyncConstant.PERF_TYPE_COLLECTION, flow.getKingdeeBillNo(), flow.getId(),
"收款履约-" + firstNonBlank(flow.getReceiptNoticeNo(), flow.getDetailSerialNo()));
// shortcut: 履约流水 fdId 未持久化(认领链无 C108-2 更新场景,重推会重复);需推进实际金额更新时先落库
log.info("HCS履约流水新建完成 flowId:{},contractNo:{},perfFdId:{}", flow.getId(), contractManage.getContractNo(), perfFdId);
HcsContractExeStage stageRow, BigDecimal claimAmount, LocalDate planDate) {
// 表单模板ID(配置 perf-record.xform-id,环境值 §9-U9,未配置不传)
String xformId = StringUtil.isBlank(hcsProperties.getPerfRecord().getXformId()) ? null : hcsProperties.getPerfRecord().getXformId();
HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO();
// 【必填】申请编号:收款通知单号,兜底明细流水号
props.setFdApplyNo(firstNonBlank(flow.getReceiptNoticeNo(), flow.getDetailSerialNo()));
// 申请金额 / 实际金额:本次认领分摊金额
props.setFdApplyAmount(claimAmount.toPlainString());
props.setFdFactAmount(claimAmount.toPlainString());
// 【必填】履约日期:认领日期(毫秒时间戳)
props.setFdPlanDate(toMillis(planDate));
// 履约类型:收款
props.setFdPalnType(HcsSyncConstant.PERFORM_TYPE_COLLECTION);
// 履约性质:收款计划行款项性质
props.setFdProperty(stageRow.getFundNature());
// 履约计划id / 来源履约计划项ID:收款计划行合规阶段ID(HCS 只认自己的阶段ID)
props.setFdId(stageRow.getHcsStageFdId());
props.setFdPlanId(stageRow.getHcsStageFdId());
// 金蝶单据号:金蝶收款单号(A301-1 同步落库)
props.setFdJdDocNo(flow.getKingdeeBillNo());
// 【必填】合同ID:HCS 合同ID
props.setFdContractId(contractManage.getHcsFdId());
// 业务ID:TMS 收款流水ID(溯源)
props.setFdBusinessId(String.valueOf(flow.getId()));
props.setFdCreateTime(System.currentTimeMillis());
props.setFdXformId(xformId);
HcsPerfRecordFormDataDTO formData = new HcsPerfRecordFormDataDTO();
formData.setFdXformId(xformId);
formData.setDynamicProps(props);
// 机制数据(C108-1 必填):认领附件暂不转传,传空列表
HcsMechanismsDTO mechanisms = new HcsMechanismsDTO();
mechanisms.setAttachment(List.of());
HcsPerfRecordSaveDTO param = new HcsPerfRecordSaveDTO();
param.setCurUser(HcsUserRefVO.ofLoginName(hcsProperties.getOperatorLoginName()));
param.setFormData(formData);
param.setMechanisms(mechanisms);
log.info("HCS履约流水新建开始 flowId:{},contractNo:{},认领金额 {}", flow.getId(), contractManage.getContractNo(), claimAmount);
HcsResult<HcsFormSaveVO> result = hcsClient.perfRecordAdd(param);
if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
log.error("HCS履约流水新建失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo());
throw new HcsException("C108-1 履约流水新建失败:" + (result == null ? "响应为空" : result.getMsg()));
}
// shortcut: 履约流水 fdId 未持久化(认领链无 C108-2 更新场景);需推进实际金额更新时先落库
log.info("HCS履约流水新建成功 flowId:{},contractNo:{},perfFdId:{}",
flow.getId(), contractManage.getContractNo(), result.getData().getFdId());
}
/**
@@ -50,7 +50,7 @@ import java.util.List;
/**
* HCS 付款链推送编排支撑组件(结算付款特批 C106-3/4 + 履约计划重推 C103-9 + 履约记录 C108-1/2 + 特批结果推送接收,设计文档 §5.0/§5.3/§5.5/§5.6)
* <p>
* 编排顺序:特批(提交时高风险/黑名单须特批附件或额度超额标记 need 时发起)→ 履约计划重推(付款审批款项金额累加行)→ 履约记录(金蝶实际付款后新建/分期推进更新)。
* 编排顺序:特批(提交时高风险/黑名单须特批附件或额度超额标记 need 时发起)→ 金蝶实际付款门禁(未付款时履约计划与履约记录均不推送)→ 履约计划重推(付款审批累加款项金额行,金蝶付款后随链重推)→ 履约记录(金蝶实际付款后新建/分期推进更新)。
* 特批通过后不推履约数据:履约计划与履约记录统一在各业务环节推送(付款=金蝶实际付款后,收款/开票/收票各自环节)。
* 特批审核期间(need/pushing/processing)仅发起/等待 C106,不推送履约计划与履约记录;驳回不产生履约数据。
* 特批流程ID 与履约记录ID 在各自 Support 内即时持久化;整体同步状态(hcs_sync_status)由本组件统一回写。
@@ -78,7 +78,7 @@ public class HcsPaymentSyncSupport {
private final ApplicationEventPublisher eventPublisher;
/**
* 推送付款申请到 HCS:特批(need 时发起 C106-3 后挂起)→ 履约计划重推(C103-9 款项金额累加行)→ 履约记录(金蝶实际付款后新建/更新)→ 回填整体同步状态
* 推送付款申请到 HCS:特批(need 时发起 C106-3 后挂起)→ 金蝶实际付款门禁(未付款时整体跳过,付款回写后重新置待同步触发推送)→ 履约计划重推(C103-9 款项金额累加行)→ 履约记录(C108-1 新建/C108-2 更新)→ 回填整体同步状态
*
* @param payment 付款申请单(approvalStatus=approved)
* @return 推送结果描述
@@ -104,8 +104,16 @@ public class HcsPaymentSyncSupport {
markSynced(payment, message);
return message;
}
// ② 履约计划重推(C103-9 行级更新):付款审批通过累加款项金额置 row_op=update 的计划行,
// 凭已存合规阶段ID 重推;独立于履约记录开关,推送失败抛出由付款链置 failed 重试兜底
// ② 金蝶实际付款门禁:履约计划与履约记录均待金蝶实际付款后推送(需求口径:金蝶付款成功后再推给合同系统)——
// 审批通过仅本地累加计划金额置待同步,此处不重推;未付款时整体照常回写,
// 付款回写推进(applyPayApplyResult)重新置待同步后由扫描任务触发本推送
if (payment.getPaidAmount() == null || payment.getPaidAmount().signum() <= 0) {
String message = "金蝶未实际付款,待付款后推送履约计划与履约记录";
markSynced(payment, message);
return message;
}
// ③ 履约计划重推(C103-9 行级更新):付款审批通过累加款项金额置 row_op=update 的计划行,
// 凭已存合规阶段ID 重推;推送失败抛出由付款链置 failed 重试兜底
if (hcsProperties.getExeStage().isSyncEnabled()) {
String stageResult = this.pushExeStageUpdate(payment);
if (StringUtil.isNotBlank(stageResult)) {
@@ -115,24 +123,11 @@ public class HcsPaymentSyncSupport {
result.append(stageResult);
}
}
// ③ 履约记录:金蝶实际付款后推送(需求口径:金蝶实际付款了再推给合同系统)——
// 首次回写实际付款走 C108-1 新建,分期付款推进(paidAmount 变化置待同步)走 C108-2 更新;
// 未实际付款时跳过(整体状态照常回写,付款回写时重新置待同步触发推送)
// ④ 履约记录:首次回写实际付款走 C108-1 新建,分期付款推进(paidAmount 变化置待同步)走 C108-2 更新
if (!hcsProperties.getPerfRecord().isSyncEnabled()) {
markSynced(payment, result.toString());
return result.toString();
}
if (payment.getPaidAmount() == null || payment.getPaidAmount().signum() <= 0) {
if (result.length() > 0) {
result.append(";");
}
result.append("金蝶未实际付款,待付款后推送履约记录");
markSynced(payment, result.toString());
return result.toString();
}
if (result.length() > 0) {
result.append(";");
}
result.append(hcsPerfRecordSyncSupport.pushPerfRecord(payment));
markSynced(payment, result.toString());
return result.toString();
@@ -1,67 +0,0 @@
/**
* 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
* is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for their own commercial purposes.
* <p>
* Using this software signifies agreement to this license agreement and terms of use,
* and you expressly agree to be bound by the terms of use of this software.
* <p>
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED
* WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
* FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.service.IReceiptFlowService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 收款认领推送金蝶定时任务(A301-3 收款认领结果推送)
* <p>
* 认领动作只在本地登记,本任务扫描「认领完成且未推送过」的收款流水自动推送金蝶,
* 与收款流水页「推送金蝶」手动按钮共用同一服务方法(推送前按认领人重查 A301-2 最新公告对账)。
* 单条流水推送失败独立消化、可重试,不影响同批其他流水。
* <p>
* 调度配置(Nacos thirdParty.bfi.receiptClaimPush.*):syncEnabled 开关、cron 表达式。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ReceiptClaimPushTask {
private final IReceiptFlowService receiptFlowService;
private final BfiProperties bfiProperties;
/** 收款认领推送调度,默认每10分钟执行一次 */
@Scheduled(cron = "${thirdParty.bfi.receiptClaimPush.cron:0 4/10 * * * ?}")
public void pushClaimToKingdee() {
if (!bfiProperties.getReceiptClaimPush().isSyncEnabled()) {
return;
}
try {
String summary = receiptFlowService.pushClaimByTask();
log.info("定时推送收款认领:{}", summary);
} catch (Exception exception) {
log.error("定时推送收款认领异常", exception);
}
}
}
@@ -70,15 +70,10 @@ public class ReceiptFlowWrapper extends BaseEntityWrapper<KingdeeReceiptFlow, Re
case "failed" -> "处理失败";
default -> "未推送";
});
// 剩余可认领金额 = 可认领上限 − 累计认领金额(上限取公告未认领与收款金额较小值)
BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount());
// 剩余可认领金额 = 收款金额 − 累计认领金额(认领只登记本地,额度由本地累计认领控制)
BigDecimal receiptAmount = money(entity.getReceiptAmount());
BigDecimal claimCeiling = unclaimedAmount;
if (receiptAmount.compareTo(BigDecimal.ZERO) > 0 && unclaimedAmount.compareTo(BigDecimal.ZERO) > 0) {
claimCeiling = unclaimedAmount.min(receiptAmount);
}
BigDecimal claimedAmount = money(entity.getClaimedAmount());
vo.setRemainingAmount(claimCeiling.subtract(claimedAmount).max(BigDecimal.ZERO));
vo.setRemainingAmount(receiptAmount.subtract(claimedAmount).max(BigDecimal.ZERO));
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser()));
return vo;