✨ 应收正式结算单同步金蝶财务应收单(A302-2保存+A302-4幂等预检与定时勾稽纠偏)

This commit is contained in:
刘泉佳
2026-09-21 19:14:05 +08:00
parent b3eee6347b
commit 82b347cb18
8 changed files with 573 additions and 37 deletions
@@ -21,6 +21,8 @@ import java.time.LocalDateTime;
/**
* 正式结算单实体类
* <p>
* 结算单主表,明细快照见 {@link FormalSettlementDetail},合计费用见 {@link FormalSettlementSummaryFee}
*
* @author Chill
*/
@@ -30,38 +32,140 @@ import java.time.LocalDateTime;
@Schema(description = "正式结算单")
public class FormalSettlement extends TenantEntity {
@Serial private static final long serialVersionUID = 1L;
/**
* 正式结算单号(同租户唯一)
*/
private String formalSettlementNo;
/**
* 来源类型:预结算合并/应收应付/混合来源
*/
private String sourceType;
/**
* 结算类型:receivable-应收 payable-应付
*/
private String settlementType;
/**
* 所属项目ID
*/
private Long projectId;
/**
* 所属项目名称
*/
private String projectName;
/**
* 所属组织ID
*/
private Long deptId;
/**
* 所属组织名称
*/
private String deptName;
/**
* 结算合同ID
*/
private Long contractId;
/**
* 结算合同编号
*/
private String contractNo;
/**
* 结算合同名称
*/
private String contractName;
/**
* 付款方名称
*/
private String payerName;
/**
* 收款方名称
*/
private String payeeName;
/**
* 结算币种,默认 RMB
*/
private String currency;
/**
* 结算金额(结算币种,含全部合计费用)
*/
private BigDecimal settlementAmount;
/**
* 本位币,默认 RMB
*/
private String localCurrency;
/**
* 本位币结算金额 = 结算金额 × 结算汇率
*/
private BigDecimal localSettlementAmount;
/**
* 已申请付款金额,含来源预结算的预付
*/
private BigDecimal appliedPaymentAmount;
/**
* 已收/已付金额合计,含来源预结算的预付
*/
private BigDecimal paidAmount;
/**
* 剩余可付金额 = 结算金额 - 已收已付合计,最小为 0
*/
private BigDecimal remainingPayableAmount;
/**
* 已开票/已收票金额,取开票申请与收票登记的分摊合计
*/
private BigDecimal invoiceAmount;
/**
* 汇率日期
*/
private LocalDate exchangeRateDate;
/**
* 结算汇率
*/
private BigDecimal exchangeRate;
/**
* 开票状态(应收为开票、应付为收票):unreceived-未开票 partial-部分开票 completed-已开票
*/
private String invoiceStatus;
/**
* 收付款状态:unpaid-未收付款 partial-部分收付款 paid-已收付款
*/
private String paymentStatus;
/**
* 审核状态:draft-草稿 reviewing-审批中 approved-审批通过 returned-已驳回 voided-已作废
*/
private String approvalStatus;
/**
* 当前审批节点,如「草稿」「财务审核」「审批通过」
*/
private String currentNode;
/**
* 当前处理人姓名
*/
private String currentProcessor;
/**
* 金蝶单据编号
*/
private String kingdeeBillNo;
/**
* 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败
*/
private String kingdeeSyncStatus;
/**
* 附件JSON
*/
private String attachmentsJson;
/**
* 备注
*/
private String remark;
/**
* 审核通过时间
*/
private LocalDateTime approvedTime;
/**
* 同步金蝶时间
*/
private LocalDateTime syncedTime;
/**
* 作废原因,驳回时记录驳回原因
*/
private String voidReason;
}
@@ -55,6 +55,11 @@ public class BfiProperties {
*/
private PayApply payApply = new PayApply();
/**
* 开票申请推送配置(A303-1 开票申请单新增 / A303-5 开票申请发票查询)
*/
private InvoiceApply invoiceApply = new InvoiceApply();
/**
* 付款申请推送配置项
*/
@@ -77,4 +82,17 @@ public class BfiProperties {
private String srcType = "0KzXs";
}
/**
* 开票申请推送配置项(A303-1 / A303-5):
* 销方名称取申请单开票单位快照、销方税号取客商档案统一社会信用代码、税收分类编码取商品行开票内容快照,均不走配置
*/
@Data
public static class InvoiceApply {
/**
* 分录外部单据类型,默认 0KzXs(与付款申请对齐)
*/
private String srcType = "0KzXs";
}
}
@@ -104,6 +104,30 @@ public final class KingdeeBfiConstant {
Map.entry("标准销售应收", "arfin_standard_BT_S")
);
/** A302-2 默认单据类型名称:服务销售应收(经 AR_BILL_BILLTYPE_MAP 转换为编码)。待确认:具体类型需金蝶侧最终确认,确认后修改此名称即可。 */
public static final String BFI_AR_BILL_BILLTYPE_NAME = "服务销售应收";
/** A302-2 款项性质编码(暂按 1001 一般销售,与应付 2001 材料采购区分)。待确认:编码需金蝶侧最终确认,确认后如有调整直接改此常量。 */
public static final String BFI_AR_BILL_PAY_PROPERTY = "1001";
/** A302-2 付款方式:现销 CASH(与应付现购对称,由金蝶自动推收款)。待确认:取值需金蝶侧确认。 */
public static final String BFI_AR_BILL_PAY_MODE_CASH = "CASH";
/** A302-2 必填:外部单据类型(应收单来源标识)。待确认:取值需金蝶侧确认,确认后如有调整直接改此常量。 */
public static final String BFI_AR_BILL_SRC_TYPE = "TMS财务应收单";
/** A303-1 上游来源单据类型:财务应收单(awrp_src_bt)。 */
public static final String BFI_AR_BILL_SRC_BT = "ar_finarbill";
/** A303-1 发票种类:数电发票(普通发票)。 */
public static final String BFI_INVOICE_TYPE_DIGITAL_GENERAL = "10xdp";
/** A303-1 发票种类:数电发票(增值税专用发票)。 */
public static final String BFI_INVOICE_TYPE_DIGITAL_SPECIAL = "08xdp";
/** 金蝶同步状态:开票完成(A303-5 查到发票,终态,开票申请专用)。 */
public static final String KINGDEE_STATUS_INVOICED = "invoiced";
/** 付款申请单(ap_payapply)单据类型映射:中文名称 -> 星瀚编码(使用规则待业务确认)。 */
public static final Map<String, String> PAY_APPLY_BILLTYPE_MAP = Map.of(
"采购付款申请", "ap_payapply_BT_S",
@@ -206,11 +206,16 @@ public class FormalSettlementController extends BladeController {
@PostMapping("/sync-kingdee")
@ApiOperationSupport(order = 14)
@Operation(summary = "推送金蝶应付单")
@Operation(summary = "推送金蝶(应付单/应收单)")
public R<String> syncKingdee(@RequestParam Long id) { return R.data(formalSettlementService.syncKingdee(id)); }
@GetMapping("/detail-fees")
@PostMapping("/sync-kingdee-ar-result")
@ApiOperationSupport(order = 15)
@Operation(summary = "手动勾稽金蝶应收单(A302-4 回写纠偏)")
public R<String> syncKingdeeArResult() { return R.data(formalSettlementService.syncKingdeeArResult()); }
@GetMapping("/detail-fees")
@ApiOperationSupport(order = 16)
@Operation(summary = "正式结算货物费用快照")
public R<List<FormalSettlementDetailFee>> detailFees(@RequestParam Long detailId) {
return R.data(formalSettlementService.detailFees(detailId));
@@ -42,6 +42,7 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
void returnBill(FormalSettlementStatusRequest request);
void voidBill(FormalSettlementStatusRequest request);
String syncKingdee(Long id);
String syncKingdeeArResult();
List<FormalSettlementDetailFee> detailFees(Long detailId);
void adjustDetail(PreSettlementDetailAdjustRequest request);
void refreshPaymentSummary(Long settlementId);
@@ -90,6 +90,7 @@ import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.wrapper.PreSettlementWrapper;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.transport.wrapper.FormalSettlementWrapper;
@@ -154,6 +155,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
private final IPreSettlementService preSettlementService;
private final IWaybillService waybillService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
private final IBfiService bfiService;
@Override
@@ -495,11 +497,6 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
public String syncKingdee(Long id) {
FormalSettlement settlement = existing(id);
if (!APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许同步金蝶");
// 单据类型路由:应付对应财务应付单(A102-2),应收对应财务应收单(A302-2),推送规则待业务确认,暂拦截报错
if (!"payable".equals(settlement.getSettlementType())) {
throw new ServiceException("推送金蝶失败:结算单[" + Func.toStr(settlement.getFormalSettlementNo())
+ "]结算类型为应收,对应财务应收单(A302-2),推送规则待业务确认后启用");
}
// 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据)
if ("synced".equals(settlement.getKingdeeSyncStatus())) return settlement.getKingdeeBillNo();
// 流程拆分:正式结算单「同步金蝶」负责推送财务应付单(A102-2)并回写结算单单据编号;
@@ -509,22 +506,92 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
if (Func.isEmpty(details)) {
throw new ServiceException("推送金蝶财务应付单失败:正式结算单无结算明细,无法解析物料与计量单位");
throw new ServiceException("推送金蝶失败:正式结算单无结算明细,无法解析物料与计量单位");
}
KingdeeSyncContext context = KingdeeSyncContext.empty();
// 财务应付单防重键(awrp_src_code):正式结算单号_结算明细单据号下划线拼接,
// 付款申请 A103-1 下推前的前置校验与付款计划反查均按该键勾稽
String apBillSrcCode = kingdeeApBillSyncSupport.apBillSrcCode(settlement, details);
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups = kingdeeApBillSyncSupport.resolveEntryGroups(details, context);
KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeApBillSyncSupport.pushApBill(
apBillSrcCode, settlement.getContractId(), entryGroups, settlement.getFormalSettlementNo(), context);
// 回写财务应付单单据编号(A102-2 billNo)与同步状态;付款申请侧回填的付款申请单编号(A103-2)不是同一单据
settlement.setKingdeeBillNo(outcome.billNo());
// 单据类型路由:应付对应财务应付单(A102-2),应收对应财务应收单(A302-2),两链路同一套幂等预检与推送逻辑
String billNo;
String changeDesc;
if ("receivable".equals(settlement.getSettlementType())) {
// 应收结算:财务应收单防重键(awrp_src_code):正式结算单号_结算明细单据号下划线拼接
String arBillSrcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details);
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups = kingdeeArBillSyncSupport.resolveArEntryGroups(details, context);
KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeArBillSyncSupport.pushArBill(
arBillSrcCode, settlement.getContractId(), settlement.getPayerName(), entryGroups,
settlement.getFormalSettlementNo(), context);
billNo = outcome.billNo();
changeDesc = "同步金蝶财务应收单" + billNo;
} else {
// 应付结算:财务应付单防重键,付款申请「同步金蝶」(A103-1 应付下推)推送前按该键经 A102-4 预检本单应付单已生成
// 付款申请 A103-1 下推前的前置
String apBillSrcCode = kingdeeApBillSyncSupport.apBillSrcCode(settlement, details);
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups = kingdeeApBillSyncSupport.resolveEntryGroups(details, context);
KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeApBillSyncSupport.pushApBill(
apBillSrcCode, settlement.getContractId(), entryGroups, settlement.getFormalSettlementNo(), context);
billNo = outcome.billNo();
changeDesc = "同步金蝶财务应付单" + billNo;
}
// 回写金蝶单据编号(A102-2/A302-2 单据编号)与同步状态;付款申请侧回填的付款申请单编号(A103-2)不是同一单据
settlement.setKingdeeBillNo(billNo);
settlement.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
settlement.setSyncedTime(LocalDateTime.now());
updateById(settlement);
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "同步金蝶财务应付单" + outcome.billNo(), "");
return outcome.billNo();
saveChange(settlement.getId(), "结算单基本信息", null, "调整", changeDesc, "");
return billNo;
}
@Override
public String syncKingdeeArResult() {
// 待勾稽应收结算单:审批通过 + 结算类型应收 + 金蝶状态为 已同步/生成失败(已同步一并轮询为补齐单号,生成失败纳入勾稽纠偏:
// 金蝶侧已生成说明上次推送实际成功,查到即回填纠正为已同步,避免人工重新推送产生重复单据)
List<FormalSettlement> settlements = list(Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getApprovalStatus, APPROVED)
.eq(FormalSettlement::getSettlementType, "receivable")
.in(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED,
KingdeeBfiConstant.KINGDEE_STATUS_FAILED)
.orderByAsc(FormalSettlement::getId));
if (Func.isEmpty(settlements)) {
return "金蝶应收单勾稽:无待勾稽应收结算单";
}
// 组装防重键(与 A302-2 保存传参一致),同一防重键对应一张财务应收单
Map<String, FormalSettlement> settlementBySrcCode = new LinkedHashMap<>();
for (FormalSettlement settlement : settlements) {
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId())
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
String srcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details);
if (Func.isNotBlank(srcCode)) {
settlementBySrcCode.putIfAbsent(srcCode, settlement);
}
}
// A302-4 批量查询:查到 → 回填金蝶单据编号(缺失时)、生成失败纠偏为已同步;查不到 → 状态保持不变
Map<String, String> existBillNos = kingdeeArBillSyncSupport.queryArBillExistNos(new ArrayList<>(settlementBySrcCode.keySet()));
int updated = 0;
int corrected = 0;
for (Map.Entry<String, FormalSettlement> entry : settlementBySrcCode.entrySet()) {
String billNo = existBillNos.get(entry.getKey());
if (Func.isBlank(billNo)) {
continue;
}
FormalSettlement settlement = entry.getValue();
boolean failed = KingdeeBfiConstant.KINGDEE_STATUS_FAILED.equals(settlement.getKingdeeSyncStatus());
if (!failed && Func.isNotBlank(settlement.getKingdeeBillNo())) {
continue;
}
if (failed) {
corrected++;
}
if (Func.isBlank(settlement.getKingdeeBillNo())) {
settlement.setKingdeeBillNo(billNo);
}
settlement.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
settlement.setSyncedTime(LocalDateTime.now());
updateById(settlement);
updated++;
}
return "金蝶应收单勾稽完成:待查应收结算单" + settlementBySrcCode.size() + "笔,回写单据信息" + updated
+ "笔(生成失败纠偏" + corrected + "笔)";
}
@Override
@@ -80,7 +80,8 @@ import java.util.stream.Collectors;
* <p>流程拆分后:财务应付单(A102-2)由正式结算单「同步金蝶」推送并回写结算单;
* 付款申请「同步金蝶」仅负责付款申请单(A103-1 应付下推 / A103-3 直接保存),
* 推送前经 A102-4 预检应付单已生成、下推时经 A102-4 反查付款计划分录 awrp_entry_code_p。
* 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件。</p>
* 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件;
* 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。</p>
*
* @author Chill
*/
@@ -414,8 +415,8 @@ public class KingdeeApBillSyncSupport {
sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount, amountNoTax);
}
/** 付款申请关联的正式结算货物费用行(blade_formal_settlement_detail_fee,用于不含税金额回链应付费用行) */
private List<FormalSettlementDetailFee> formalSettlementDetailFees(List<FormalSettlementDetail> settlementDetails) {
/** 付款申请关联的正式结算货物费用行(blade_formal_settlement_detail_fee,用于不含税金额回链应付费用行;应收单同步组件复用) */
public List<FormalSettlementDetailFee> formalSettlementDetailFees(List<FormalSettlementDetail> settlementDetails) {
List<Long> settlementDetailIds = settlementDetails.stream().map(FormalSettlementDetail::getId)
.filter(Objects::nonNull).distinct().toList();
if (Func.isEmpty(settlementDetailIds)) {
@@ -427,8 +428,8 @@ public class KingdeeApBillSyncSupport {
.orderByAsc(FormalSettlementDetailFee::getLineNo));
}
/** 应付明细id->应付货物费用行(blade_receivable_payable_cargo_fee,经结算明细 sourceDetailId 关联,每货物一条,分录主取数来源) */
private Map<Long, List<ReceivablePayableCargoFee>> payableCargoFeesByDetailId(List<FormalSettlementDetail> settlementDetails) {
/** 应付明细id->应付货物费用行(blade_receivable_payable_cargo_fee,经结算明细 sourceDetailId 关联,每货物一条,分录主取数来源;应收单同步组件复用) */
public Map<Long, List<ReceivablePayableCargoFee>> payableCargoFeesByDetailId(List<FormalSettlementDetail> settlementDetails) {
List<Long> sourceDetailIds = settlementDetails.stream()
.map(FormalSettlementDetail::getSourceDetailId).filter(Objects::nonNull).distinct().toList();
if (Func.isEmpty(sourceDetailIds)) {
@@ -444,9 +445,9 @@ public class KingdeeApBillSyncSupport {
/**
* 应付货物费用行id->结算金额(不含税):应付费用行无不含税字段,经结算货物费用行 sourceFeeId 回链获取。
* 直接选应收应付明细路径 sourceFeeId=应付费用行id;预结算合并路径 sourceFeeId=预结算费用行id,
* 经预结算费用行 sourceFeeId(指向应付费用行)回溯;无对应结算费用行时无映射,调用方兜底 0
* 经预结算费用行 sourceFeeId(指向应付费用行)回溯;无对应结算费用行时无映射,调用方兜底 0(应收单同步组件复用)
*/
private Map<Long, BigDecimal> settlementNoTaxByPayableFeeId(List<FormalSettlementDetail> settlementDetails) {
public Map<Long, BigDecimal> settlementNoTaxByPayableFeeId(List<FormalSettlementDetail> settlementDetails) {
List<FormalSettlementDetailFee> settlementFees = formalSettlementDetailFees(settlementDetails);
if (Func.isEmpty(settlementFees)) {
return Map.of();
@@ -473,8 +474,8 @@ public class KingdeeApBillSyncSupport {
return noTaxByPayableFeeId;
}
/** 货物类型名称->货物编码映射(取自货物类型档案;名称重复且编码不一致时拦截;批量同步时全批共享一次加载结果) */
private Map<String, String> cargoCodeByName(KingdeeSyncContext context) {
/** 货物类型名称->货物编码映射(取自货物类型档案;名称重复且编码不一致时拦截;批量同步时全批共享一次加载结果;应收单同步组件复用) */
public Map<String, String> cargoCodeByName(KingdeeSyncContext context) {
Map<String, String> cache = context.cargoCodeByName();
if (!cache.isEmpty()) {
return cache;
@@ -506,18 +507,18 @@ public class KingdeeApBillSyncSupport {
*
* @param priceUnit 快照运费计算单位(可能为空)
* @param fallbackUnit 兜底数量单位
* @return 计量单位编码,无法解析时返回 null
* @return 计量单位编码,无法解析时返回 null(应收单同步组件复用)
*/
private String resolveMeasureunitNumber(String priceUnit, String fallbackUnit) {
public String resolveMeasureunitNumber(String priceUnit, String fallbackUnit) {
String unitName = Func.isNotBlank(priceUnit) ? priceUnit : fallbackUnit;
return Func.isBlank(unitName) ? null : unitName.trim();
}
/**
* 货物类型支持“、/,”分隔的多类型写法,解析为类型名称列表;
* 分录不按类型拆行(业务约定整行一条),物料编码仅取列表第一种类型
* 分录不按类型拆行(业务约定整行一条),物料编码仅取列表第一种类型(应收单同步组件复用)
*/
private List<String> splitCargoTypes(String cargoType) {
public List<String> splitCargoTypes(String cargoType) {
if (Func.isBlank(cargoType)) {
throw new ServiceException("推送金蝶财务应付单失败:结算明细货物类型为空,无法解析物料编码");
}
@@ -629,8 +630,8 @@ public class KingdeeApBillSyncSupport {
return customerCode;
}
/** 按乙方名称(全称或简称)查询客商编码,未维护返回 null */
private String queryCustomerCode(String partyB) {
/** 按乙方名称(全称或简称)查询客商编码,未维护返回 null(应收单同步组件复用:按结算单付款方查询) */
public String queryCustomerCode(String partyB) {
CustomerArchive customer = customerArchiveMapper.selectOne(Wrappers.<CustomerArchive>lambdaQuery()
.and(wrapper -> wrapper.eq(CustomerArchive::getFullName, partyB).or().eq(CustomerArchive::getShortName, partyB))
.eq(CustomerArchive::getIsDeleted, 0).last("limit 1"));
@@ -643,16 +644,16 @@ public class KingdeeApBillSyncSupport {
* @param items 待处理列表
* @param batchSize 批大小
* @param batchConsumer 批处理逻辑
* @param <T> 元素类型
* @param <T> 元素类型(应收单同步组件复用)
*/
private <T> void batchForeach(List<T> items, int batchSize, Consumer<List<T>> batchConsumer) {
public <T> void batchForeach(List<T> items, int batchSize, Consumer<List<T>> batchConsumer) {
for (int from = 0; from < items.size(); from += batchSize) {
batchConsumer.accept(items.subList(from, Math.min(from + batchSize, items.size())));
}
}
/** 金额空值兜底并保留 2 位小数 */
private BigDecimal money(BigDecimal value) {
/** 金额空值兜底并保留 2 位小数(应收单同步组件复用) */
public BigDecimal money(BigDecimal value) {
return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP);
}
@@ -0,0 +1,316 @@
/**
* 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.support;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.Func;
import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveEntryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 金蝶财务应收单(A302-2)同步支撑组件。
* <p>应收(receivable)正式结算单「同步金蝶」推送财务应收单(A302-2),推送前经 A302-4 按防重键幂等预检,
* 与应付链路(A102-2/A102-4,{@link KingdeeApBillSyncSupport})同一套逻辑;防重键组装、A302-4 查询、
* A302-2 组装保存与应收分录解析收口在本组件,档案取数(合同/组织/币种/客商/货物类型/不含税回链等)
* 复用应付支撑组件的公共方法。</p>
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KingdeeArBillSyncSupport {
/** A302-4 防重键批量分批查询单次条数上限(与 A102-4 一致) */
private static final int AR_RESULT_QUERY_BATCH = 50;
/** A302-4 分页查询页大小 */
private static final int AR_RESULT_PAGE_SIZE = 200;
private final IBfiService bfiService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final BfiProperties bfiProperties;
// ==================== 财务应收单防重键(awrp_src_code) ====================
/**
* 财务应收单防重键(awrp_src_code):正式结算单号 + 结算明细单据号下划线拼接(与 A302-2 保存传参一致),
* 规则与财务应付单(A102-2)同源,一张应收结算单只生成一张金蝶财务应收单
*
* @param settlement 正式结算单
* @param settlementDetails 该结算单下的结算明细
* @return 防重键
*/
public String arBillSrcCode(FormalSettlement settlement, List<FormalSettlementDetail> settlementDetails) {
return ExternalSrcCodeSupport.buildSrcCode(settlement.getFormalSettlementNo(),
kingdeeApBillSyncSupport.settlementDocumentNos(settlementDetails));
}
// ==================== A302-4 财务应收单查询 ====================
/**
* A302-4 批量查询已生成财务应收单:来源单据号批量 + 外部系统编号(防重键),自动翻页,
* 用于推送前幂等预检与定时任务应收勾稽纠偏(awrp_src_code 支持多值,整批一次查询消除逐单远程查询 N+1)
*
* @param awrpSrcCodes 来源单据号(正式结算单号_结算明细单据号拼接)批次
* @return 已生成财务应收单的 来源单据号->单据编号 映射
*/
public Map<String, String> queryArBillExistNos(List<String> awrpSrcCodes) {
Map<String, String> existBillNos = new LinkedHashMap<>();
kingdeeApBillSyncSupport.batchForeach(awrpSrcCodes, AR_RESULT_QUERY_BATCH, batch -> {
BfiArBillQueryDTO param = new BfiArBillQueryDTO();
param.setAwrpSrcCode(batch);
param.setAwrpSrcSys(bfiProperties.getSrcSys());
for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(AR_RESULT_PAGE_SIZE);
return bfiService.queryArBill(param);
})) {
if (Func.isNotEmpty(arBill.getAwrpSrcCode())) {
existBillNos.put(arBill.getAwrpSrcCode(), arBill.getBillno());
}
}
});
return existBillNos;
}
// ==================== A302-2 财务应收单保存 ====================
/**
* 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。
* 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
* 明细分录逐应收货物费用行生成(不聚合),含税金额取应收货物费用行调整后总金额、不含税取结算货物费用行不含税;
* 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成
*
* @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接)
* @param contractId 正式结算单关联合同id
* @param payerName 结算单付款方(客户名称),往来户取数来源
* @param entryGroups 结算明细解析出的应收单分录
* @param subject 业务单据号(正式结算单号,日志与异常提示用)
* @param context 金蝶同步上下文(缓存载体,预检命中与应付共用防重键缓存)
* @return 财务应收单推送结果(单据编号 + 是否金蝶已存在幂等跳过)
*/
public KingdeeApBillSyncSupport.BfiApBillPushOutcome pushArBill(String arBillSrcCode, Long contractId, String payerName,
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups,
String subject, KingdeeSyncContext context) {
// A302-4 幂等预检:按防重键查询金蝶是否已生成财务应收单,已存在则幂等跳过保存,直接返回已有单据编号
String existBillNo = context.apBillNoCache().computeIfAbsent(arBillSrcCode, code ->
queryArBillExistNos(List.of(code)).getOrDefault(code, ""));
if (Func.isNotEmpty(existBillNo)) {
log.info("正式结算单[{}]金蝶财务应收单已生成(单据编号[{}]),幂等跳过 A302-2 保存", subject, existBillNo);
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, true);
}
ContractManage contract = kingdeeApBillSyncSupport.requireContract(contractId, context);
// 单据类型按映射字典转换:默认服务销售应收,金蝶确认其他类型后修改 BFI_AR_BILL_BILLTYPE_NAME 即可
String billtypeNumber = KingdeeBfiConstant.billtypeBy(KingdeeBfiConstant.AR_BILL_BILLTYPE_MAP, KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME);
if (Func.isBlank(billtypeNumber)) {
throw new ServiceException("推送金蝶财务应收单失败:单据类型[" + KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME + "]未在映射字典中维护");
}
String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract);
// 币种取合同结算币种
String currency = kingdeeApBillSyncSupport.bfiCurrency(contract);
// 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),以同步当日作为应收单据日期
String billDateText = kingdeeApBillSyncSupport.bfiDateTimeText(LocalDate.now());
// 往来户.编码:应收结算单付款方(客户)关联客商档案客商编码
if (Func.isBlank(payerName)) {
throw new ServiceException("推送金蝶财务应收单失败,结算单付款方不能为空,请检查正式结算单[" + Func.toStr(subject) + "]付款方信息");
}
String customerCode = context.customerCodeCache().computeIfAbsent(payerName.trim(), kingdeeApBillSyncSupport::queryCustomerCode);
if (Func.isBlank(customerCode)) {
throw new ServiceException("推送金蝶财务应收单失败,结算单付款方[" + payerName + "]未在客商档案中维护客商编码,请补充后重试");
}
BfiArBillSaveDTO param = new BfiArBillSaveDTO();
// 【必填】单据类型.编码
param.setBilltypeNumber(billtypeNumber);
// 【必填】结算组织.编码
param.setOrgNumber(orgNumber);
// 收款组织.编码:同结算组织
param.setRecorgNumber(orgNumber);
// 【必填】单据日期
param.setBizdate(billDateText);
// 【必填】往来类型:应收固定客户
param.setAsstacttype(KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE);
// 【必填】往来户.编码:结算单付款方(客户)
param.setAsstactNumber(customerCode);
// 【必填】付款方式(CASH:现销, CREDIT:赊销),现销由金蝶自动推收款
param.setPaymode(KingdeeBfiConstant.BFI_AR_BILL_PAY_MODE_CASH);
// 【必填】结算币别.货币代码
param.setCurrencyNumber(currency);
// 【必填】款项性质.编码
param.setPaypropertyNumber(KingdeeBfiConstant.BFI_AR_BILL_PAY_PROPERTY);
// TODO 待确认:结算方式编码(settlementtype_number)与税率编码(taxrateid_number)、到期日(duedate)需金蝶侧确认后补充,当前不传
// 录入含税价(非必传)
param.setIsincludetax(true);
// 录入总价,为 true 后可不录入单价(非必传)
param.setIspricetotal(true);
// 【必填】外部系统编号(候选键)
param.setAwrpSrcSys(bfiProperties.getSrcSys());
// 【必填】外部系统来源单据号(候选键):正式结算单号_结算明细单据号1_单据号2...(复用预检已组装的防重键)
param.setAwrpSrcCode(arBillSrcCode);
// 来源单据(非必传,有上游合同时传 awrp_contract_info),当前与应付单保持一致不传
// 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_AR_BILL_SRC_TYPE);
// 明细分录:每条应收货物费用行一条(不聚合),无吨位时数量兜底 1
List<BfiArBillSaveEntryDTO> entry = new ArrayList<>();
for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) {
BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO();
// 【必填】物料编码.编码:结算明细货物类型匹配货物类型档案货物编码
row.setEMaterialNumber(entryGroup.materialNumber());
// 【必填】计量单位.编码:货物费用行运费计算单位(快照 price_unit,档案暂无编码字段暂传单位名称)
row.setEMeasureunitNumber(entryGroup.measureunitNumber());
// 【必填】数量:该行运输数量,无吨位数据时兜底 1
row.setEQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
// 明细.单价:该行运输单价(费用明细 unit_price;单据头 ispricetotal=true 录入总价,单价仅作参考)
row.setEUnitprice(entryGroup.price());
// 应收金额(含税):该行应收货物费用行调整后总金额(含税)
row.setERecamount(entryGroup.amount());
// 金额(不含税):该行结算金额(不含税)(应收费用行无不含税字段,经结算货物费用行回链)
row.setEAmount(entryGroup.amountNoTax());
// 【必填】项目.项目编码(联调期临时传金蝶测试档案值)
row.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST);
// 调试
if (true) {
// 【必填】物料编码.编码:结算明细货物类型匹配货物类型档案货物编码
row.setEMaterialNumber(KingdeeBfiConstant.BFI_AP_BILL_MATERIAL_NUMBER_TEST);
// 【必填】计量单位.编码:货物费用行运费计算单位(快照 price_unit,档案暂无编码字段暂传单位名称)
row.setEMeasureunitNumber(KingdeeBfiConstant.BFI_AP_BILL_MEASUREUNIT_NUMBER_TEST);
}
// 【必填】外部系统来源分录标识(候选键):应收货物费用行id(唯一)
row.setAwrpEntryCode(entryGroup.entryCode());
// 分录备注(非必传)
// row.setERemark(subject);
entry.add(row);
}
param.setEntry(entry);
// 收款计划 planentity 不传:由金蝶按明细自动汇总生成(启用方案待业务确认后迭代)
BfiBillSaveResultVO saveResult = bfiService.saveArBill(param);
// 应收单id不落库,后续如需可经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶财务应收单");
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(saveResult.getResult().get(0).getNumber(), false);
}
// ==================== 结算明细分录解析 ====================
/**
* 解析正式结算明细,逐行生成应收单分录(不聚合,每条来源费用行一条分录),分录携带所属正式结算单id。
* <p>取数来源与应付单分录同源:结算明细 sourceDetailId 关联应收应付货物费用行(blade_receivable_payable_cargo_fee,
* 每货物一条)——货物类型/计量单位/运输数量/含税金额(调整后总金额 after_amount)直接取费用行;不含税金额经结算
* 货物费用行 sourceFeeId 回链;无货物费用行时直接报错拦截。物料编码取第一种货物类型匹配货物类型档案货物编码;
* 分录唯一标识(awrp_entry_code)取费用明细行id。</p>
*
* @param settlementDetails 正式结算明细列表
* @param context 金蝶同步上下文(缓存载体)
* @return 应收单分录列表(保持行序)
*/
public List<KingdeeApBillSyncSupport.BfiEntryGroup> resolveArEntryGroups(List<FormalSettlementDetail> settlementDetails,
KingdeeSyncContext context) {
if (Func.isEmpty(settlementDetails)) {
throw new ServiceException("推送金蝶财务应收单失败:正式结算单无结算明细,无法解析物料与计量单位");
}
Map<String, String> cargoCodeByName = kingdeeApBillSyncSupport.cargoCodeByName(context);
// 应收应付明细id->货物费用行(分录主取数来源,应收应付共用同一张费用行表)
Map<Long, List<ReceivablePayableCargoFee>> feesByDetailId = kingdeeApBillSyncSupport.payableCargoFeesByDetailId(settlementDetails);
// 费用行id->结算金额(不含税):经结算货物费用行 sourceFeeId 回链
Map<Long, BigDecimal> noTaxByFeeId = kingdeeApBillSyncSupport.settlementNoTaxByPayableFeeId(settlementDetails);
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups = new ArrayList<>();
for (FormalSettlementDetail settlementDetail : settlementDetails) {
List<ReceivablePayableCargoFee> fees = feesByDetailId.getOrDefault(settlementDetail.getSourceDetailId(), List.of());
if (Func.isEmpty(fees)) {
throw new ServiceException("推送金蝶财务应收单失败:结算明细[" + Func.toStr(settlementDetail.getDocumentNo())
+ "]对应应收明细无货物费用行,分录取数必须来自应收费用明细,请先维护费用数据");
}
for (ReceivablePayableCargoFee fee : fees) {
appendArEntryGroups(entryGroups, cargoCodeByName, settlementDetail.getFormalSettlementId(),
fee.getCargoType(),
kingdeeApBillSyncSupport.resolveMeasureunitNumber(fee.getPriceUnit(), fee.getQuantityUnit()),
fee.getId(), kingdeeApBillSyncSupport.money(fee.getUnitPrice()),
kingdeeApBillSyncSupport.money(fee.getAfterAmount()),
noTaxByFeeId.getOrDefault(fee.getId(), BigDecimal.ZERO),
kingdeeApBillSyncSupport.money(fee.getTransportQuantity()),
"应收货物费用[" + Func.toStr(fee.getCargoName()) + " 第" + Func.toStr(fee.getLineNo()) + "行]");
}
}
return entryGroups;
}
/**
* 单条应收货物费用行生成一条分录;货物类型含多种(“、/,”分隔)时不拆行,物料编码取第一种货物类型,金额数量整行直送
*
* @param entryGroups 分录集合(保持行序)
* @param cargoCodeByName 货物类型名称->货物编码映射
* @param formalSettlementId 分录所属正式结算单id
* @param cargoType 货物类型(支持“、”分隔多类型)
* @param measureunitNumber 计量单位编码(运费计算单位,档案暂无编码字段暂传单位名称)
* @param sourceRowId 应收货物费用行id,雪花id天然唯一
* @param price 该行运输单价(费用明细 unit_price)
* @param amount 该行金额(含税)
* @param amountNoTax 该行金额(不含税)
* @param quantity 该行运输数量
* @param sourceDesc 来源行描述,用于异常提示
*/
private void appendArEntryGroups(List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups, Map<String, String> cargoCodeByName,
Long formalSettlementId, String cargoType, String measureunitNumber, Long sourceRowId,
BigDecimal price, BigDecimal amount, BigDecimal amountNoTax, BigDecimal quantity,
String sourceDesc) {
if (Func.isBlank(measureunitNumber)) {
throw new ServiceException("推送金蝶财务应收单失败:" + sourceDesc + "计量单位为空,请先维护运费计算单位");
}
// 货物类型含多种时不拆行(业务约定整行一条分录),物料编码取第一种货物类型
String cargoName = kingdeeApBillSyncSupport.splitCargoTypes(cargoType).get(0);
String materialNumber = cargoCodeByName.get(cargoName);
if (Func.isBlank(materialNumber)) {
throw new ServiceException("推送金蝶财务应收单失败:" + sourceDesc + "货物类型[" + cargoName + "]未在货物类型档案中维护编码,请补充后重试");
}
// 分录唯一标识(awrp_entry_code):应收货物费用行id,雪花id天然唯一
String entryCode = String.valueOf(sourceRowId);
entryGroups.add(new KingdeeApBillSyncSupport.BfiEntryGroup(materialNumber, measureunitNumber, entryCode,
quantity, price, amount, amountNoTax, formalSettlementId));
log.info("金蝶应收单分录生成:来源[{}],分录标识[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]",
sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount, amountNoTax);
}
}