!12 收票登记新增所属合同带出承办部门替代手选结算组织 + 开票申请推送金蝶送申请金额 +预付信息

Merge pull request !12 from mindless/feature/20261104-bfi-project-contract-ref-v2
This commit is contained in:
2026-11-05 07:04:28 +00:00
committed by Gitee
24 changed files with 663 additions and 77 deletions
@@ -59,10 +59,14 @@ public class InvoiceReceiptSaveRequest implements Serializable {
private String attachmentsJson;
private String remark;
/**
* 结算组织(页面手选部门,先票后款链路收票登记不再必选关联结算单)
* 结算组织(历史兼容入参:现由所属合同承办部门带出,页面不再手选)
*/
private Long deptId;
private String deptName;
/**
* 所属合同编号(承运商合同,合同名称/承办部门按编号带出,不落库)
*/
private String contractNo;
/**
* 关联结算单分摊(兼容历史入参:先票后款链路不再传,关联关系由付款结算单侧写入)
*/
@@ -112,6 +112,10 @@ public class InvoiceReceipt extends TenantEntity {
* 收款方
*/
private String payeeName;
/**
* 所属合同编号(承运商合同,结算组织=合同承办部门)
*/
private String contractNo;
/**
* 往来户金蝶供应商代码(发票销售方,保存时经客商档案/A901-1 回填,推送 A303-2 前强校验)
*/
@@ -40,4 +40,6 @@ public class PaymentApplicationRecord extends TenantEntity {
private String paymentNo;
private String voucherJson;
private String kingdeeBillNo;
/** 金蝶付款处理单id(cas_paybill.id,A103-4 回写,应付单 preentry 预付抵扣取数用) */
private String kingdeeBillId;
}
@@ -55,6 +55,22 @@ public class BfiProperties {
*/
private String srcSys = BfiConstant.DATA_SOURCE_WLTMS;
/**
* 项目编码真实化开关(thirdparty.bfi.project-real-code-enabled):开启后推送金蝶的项目编码取
* blade_project_apply.project_code 真实值(前提:金蝶项目档案已存在且编码与 project_code 一致,
* 否则该单推送失败、失败原因回写单据可重推),单据未关联项目/编码为空仍兜底 999;
* 关闭维持联调临时值 999,出问题关开关即回退
*/
private boolean projectRealCodeEnabled = false;
/**
* 合同引用开关(thirdparty.bfi.contract-ref-enabled):开启后财务应付/应收送上游合同号
* (awrp_src_bt=awrp_contract_info + awrp_src_con_no,仅 blade_contract_manage.kingdee_id 非空的已同步合同,
* 防上游合同勾稽挂空)、开票申请送合同三件套(contractno/contractdate/contractamount,contractdate 取申请日期);
* 关闭维持不送,出问题关开关即回退
*/
private boolean contractRefEnabled = false;
/**
* 付款申请推送配置(A103-3 付款申请保存)
*/
@@ -132,6 +148,20 @@ public class BfiProperties {
*/
private Material material = new Material();
/**
* 金蝶税率编码映射(A102-2 财务应付单分录 taxrateid_number):税率数值 -> 金蝶税率.编码(如 9 -> V9),
* 键为税率数值文本(如 "9"、"6.5"),推送时按分录税率查表;未维护该税率时直接拦截报错;
* 分录无税率(计费方案未配置税率组/残差组/手动结算合计费用行)时税率与税率编码均不送
*/
private Map<String, String> taxrate = new LinkedHashMap<>() {{
put("0", "V0");
put("1", "V1");
put("3", "V3");
put("6", "V6");
put("9", "V9");
put("13", "V13");
}};
/**
* 付款申请推送配置项
*/
@@ -885,7 +885,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
+ "]无商品行,无法组装分录");
}
BfiInvoiceApplySaveDTO param = this.buildInvoiceApplyParam(entity, sheetBillNo, seller, salerAddr, salerBank,
kingdeeDeptNumber, kingdeeOrgNumber, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items);
kingdeeDeptNumber, kingdeeOrgNumber, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items,
contract, kingdeeContext);
log.info("金蝶开票申请推送:开票申请[{}]发票张[{}],billno=[{}],价税合计=[{}],分录{}条",
entity.getApplicationNo(), sheet.getSheetNo(), sheetBillNo, sheet.getInvoiceAmount(), items.size());
BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param);
@@ -941,10 +942,13 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
*
* @param orgNumber 金蝶组织编码(项目业务部门反查平台公司)
* @param firstArSrcCode 上游来源单据号:首张关联结算单的金蝶应收单防重键 awrp_src_code(多张结算单取第一张)
* @param contract 首张关联结算单合同(合同三件套取数来源,开关开启时送出)
* @param kingdeeContext 金蝶同步上下文(缓存载体,项目编码取数用)
*/
private BfiInvoiceApplySaveDTO buildInvoiceApplyParam(InvoiceApplication entity, String billNo,
CustomerArchive seller, String salerAddr, String salerBank, String deptNumber, String orgNumber, CustomerArchive customer,
String firstArSrcCode, BigDecimal sheetAmount, List<BfiInvoiceApplySaveEntryDTO> items) {
String firstArSrcCode, BigDecimal sheetAmount, List<BfiInvoiceApplySaveEntryDTO> items,
ContractManage contract, KingdeeSyncContext kingdeeContext) {
BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO();
// ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ====================
// 【必填】组织.编码:合同平台公司金蝶组织编码
@@ -1005,13 +1009,16 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
// 【必填】客户.编码:受票方客商档案供应商编码(A901-1 金蝶编码),未维护时兜底客商编号
param.setCustomnameidNumber(Func.isBlank(customer.getSupplierCode()) ? customer.getCustomerCode() : customer.getSupplierCode());
// 【必填】项目.项目编码(联调期临时传金蝶测试档案值 999)
param.setAwrpProjectNumber(KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE);
// ==================== 以下为非必填字段,先注释保留,联调确认后按需放开 ====================
// 申请金额(不含税):分录不含税合计(放开时需按分摊后分录汇总)
// param.setInvoiceamount(invoiceamount);
// 申请税额:分录税额合计(放开时需按分摊后分录汇总)
// param.setTotaltax(totaltax);
// 【必填】项目.项目编码:开关开启取项目档案真实编码(金蝶档案须已存在且编码一致),未关联项目兜底 999
param.setAwrpProjectNumber(kingdeeApBillSyncSupport.kingdeeProjectNumber(entity.getProjectId(), kingdeeContext));
// 申请金额(不含税)/申请税额:按分录合计送出(金蝶按「头=明细合计」强校验,不送时金蝶自行推导,
// 与明细价税分离的舍入口径可能差 0.01 尾差导致保存被拒,2026-11-05 联调)
BigDecimal entryAmountNoTax = items.stream().map(BfiInvoiceApplySaveEntryDTO::getAmount)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal entryTax = items.stream().map(BfiInvoiceApplySaveEntryDTO::getTax)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
param.setInvoiceamount(entryAmountNoTax);
param.setTotaltax(entryTax);
// 数据来源:固定 3
// param.setBillsource("3");
@@ -1020,6 +1027,14 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
// 上游单据号:首张关联结算单的金蝶应收单防重键 awrp_src_code(联调确认金蝶按防重键匹配源单,A302-4 预检已确认存在)
param.setAwrpSrcConNo(firstArSrcCode);
// 合同三件套(非必传):开关开启时送出,contractdate 取申请日期(口径确认 2026-11-04),申请日期为空兜底当日
if (bfiProperties.isContractRefEnabled() && Func.isNotBlank(contract.getContractNo())) {
param.setContractno(contract.getContractNo());
param.setContractamount(contract.getContractAmount());
LocalDate contractDate = entity.getApplicationDate() == null ? LocalDate.now() : entity.getApplicationDate();
param.setContractdate(contractDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
}
// 数据来源系统
param.setSystemsource(bfiProperties.getSrcSys());
@@ -57,6 +57,7 @@ import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.event.KingdeeBillApprovedEvent;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.CustomerArchiveMapper;
import org.springblade.transport.mapper.CustomerContactMapper;
import org.springblade.transport.mapper.CustomerInvoiceInfoMapper;
@@ -150,6 +151,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private final InvoiceReceiptSettlementMapper settlementRelationMapper;
private final InvoiceReceiptRecordMapper recordMapper;
private final FormalSettlementMapper formalSettlementMapper;
private final ContractManageMapper contractManageMapper;
/** 票池认领关系 Mapper,实时视图的 claimed 装饰标记取数来源 */
private final FormalSettlementInvoiceMapper formalSettlementInvoiceMapper;
private final FormalSettlementDetailMapper formalSettlementDetailMapper;
@@ -423,12 +425,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
copyInvoiceInformation(entity, invoice);
if (requestedRows.isEmpty()) {
// 先票后款:结算组织=页面手选部门(合同承办部门口径);付款方=受票单位、收款方=开票单位
if (request.getDeptId() == null) {
throw new ServiceException("请选择结算组织");
// 先票后款:结算组织=所属合同承办部门(金蝶公司代码沿承办部门对应平台公司派生);付款方=受票单位、收款方=开票单位
// 所属合同仅落 contract_no,合同名称等其余信息按编号实时带出
if (Func.isBlank(request.getContractNo())) {
throw new ServiceException("请选择所属合同");
}
entity.setDeptId(request.getDeptId());
entity.setDeptName(request.getDeptName());
ContractManage contract = contractManageMapper.selectOne(Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getContractNo, request.getContractNo()));
if (contract == null) {
throw new ServiceException("所属合同" + request.getContractNo() + "不存在");
}
if (!"承运商合同".equals(contract.getContractCategory())) {
throw new ServiceException("所属合同须为承运商类型的合同");
}
if (contract.getOrganizationId() == null) {
throw new ServiceException("合同" + contract.getContractNo() + "未维护承办部门,请先在合同管理中补录");
}
entity.setContractNo(contract.getContractNo());
entity.setDeptId(contract.getOrganizationId());
entity.setDeptName(contract.getOrganizationName());
entity.setPayerName(invoice.getReceiverName());
entity.setPayeeName(invoice.getIssuerName());
// 往来户=发票销售方金蝶供应商代码(A901-1),保存时尽力回填,推送前强校验缺失阻断
@@ -735,12 +750,14 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
fetchMirrorItemsFromPool(plan.mirror());
}
log.info("收票登记[{}]开始 A303-2 推送金蝶收票单", receipt.getInvoiceNo());
ContractManage contract = kingdeeApBillSyncSupport.requireContract(plan.settlement().getContractId(), context);
// 明细分录与应付单 A102-2 同源同口径:复用同一结算明细解析结果,结算明细行+结算合计费用项各一条
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups =
kingdeeApBillSyncSupport.resolveEntryGroups(plan.details(), context);
// 组织/币种/往来户按登记所属合同派生(结算组织=合同承办部门对应平台公司金蝶编码);合同按编号实时带出
ContractManage contract = contractManageMapper.selectOne(Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getContractNo, receipt.getContractNo()));
if (contract == null) {
throw new ServiceException("所属合同" + receipt.getContractNo() + "不存在,请核实合同状态");
}
KingdeeReceivedInvoiceSyncSupport.ReceivedInvoicePushOutcome pushOutcome =
kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context);
kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, context);
billNo = pushOutcome.billNo();
billPkId = pushOutcome.billId();
log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}],星瀚内码={}", receipt.getInvoiceNo(), billNo, billPkId);
@@ -833,8 +850,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
if ("2".equals(mirror.getCheckStatus())) {
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "金蝶发票查验不通过,请先人工核实票池发票");
}
if (receipt.getDeptId() == null) {
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "收票登记未选择结算组织,无法确定金蝶结算组织编码");
if (Func.isBlank(receipt.getContractNo())) {
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "收票登记未选择所属合同,无法确定金蝶结算组织编码");
}
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, null);
}
@@ -874,7 +891,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
receipt.setKingdeePushStatus(pushStatus);
receipt.setKingdeePushMsg(pushMsg);
receipt.setKingdeePushTime(LocalDateTime.now());
updateById(receipt);
// updateById 默认忽略 null 字段,推送成功时失败原因须显式置空,改用 lambdaUpdate 落库
update(Wrappers.<InvoiceReceipt>lambdaUpdate()
.eq(InvoiceReceipt::getId, receipt.getId())
.set(InvoiceReceipt::getKingdeeReceivedInvoiceNo, receipt.getKingdeeReceivedInvoiceNo())
.set(Func.isNotBlank(receipt.getKingdeeReceivedInvoiceId()), InvoiceReceipt::getKingdeeReceivedInvoiceId, receipt.getKingdeeReceivedInvoiceId())
.set(InvoiceReceipt::getKingdeePushStatus, receipt.getKingdeePushStatus())
.set(InvoiceReceipt::getKingdeePushMsg, receipt.getKingdeePushMsg())
.set(InvoiceReceipt::getKingdeePushTime, receipt.getKingdeePushTime())
.set(InvoiceReceipt::getUpdateTime, LocalDateTime.now()));
if (writeRecord) {
record(receipt.getId(), "sync", "推送金蝶收票单", receipt.getApprovalStatus(), receipt.getApprovalStatus(),
Func.isNotBlank(pushMsg) ? pushMsg : "金蝶收票单[" + Func.toStr(receivedInvoiceNo) + "]已生成",
@@ -1146,6 +1146,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
record.setPaidDate(this.parseKingdeeDate(payBill.getPaydate()));
record.setKingdeeBillNo(billNo);
// 金蝶付款处理单内码(cas_paybill.id)落库:财务应付单 preentry 预付抵扣(y_billid)取数用
record.setKingdeeBillId(payBill.getId() == null ? null : String.valueOf(payBill.getId()));
// 付款处理单查询结果整体留档(凭证号、银行流水、电子回单等信息),便于追溯
record.setVoucherJson(JsonUtil.toJson(payBill));
if (isNew) {
@@ -1448,9 +1450,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
entry.setEApplyamount(applyAmount);
// 期望付款日(非必传)
// entry.setEExpaydate(applyDateText);
// 【必填】项目.项目编码
// entry.setProjectNumber(bfiProjectNumber(entity, context));
entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST);
// 【必填】项目.项目编码:开关开启取项目档案真实编码(金蝶档案须已存在且编码一致),未关联项目兜底 999
entry.setProjectNumber(kingdeeApBillSyncSupport.kingdeeProjectNumber(entity.getProjectId(), context));
// 【必填】对方系统唯一行id(不能重复):付款申请号-1
entry.setAwrpEntryCode(bfiEntryCode(entity.getPaymentNo(), 1));
// 备注(非必传):携带关联履约计划id(进度款计划行合规阶段ID),用于合同系统关联履约付款数据与履约计划
@@ -1557,8 +1558,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
entry.setEBebankNumber(bfiBebankNumber(entity, context));
// 【必填】申请金额:取付款申请填写的申请付款金额
entry.setEApplyamount(money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP));
// 【必填】项目.项目编码(联调期临时传金蝶测试档案值)
entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST);
// 【必填】项目.项目编码:开关开启取项目档案真实编码(金蝶档案须已存在且编码一致),未关联项目兜底 999
entry.setProjectNumber(kingdeeApBillSyncSupport.kingdeeProjectNumber(entity.getProjectId(), context));
// 期望付款日(非必传,不传时根据应付单自动带出)
// entry.setEExpaydate(applyDateText);
// 备注(非必传):携带关联履约计划id(进度款计划行合规阶段ID),用于合同系统关联履约付款数据与履约计划
@@ -1795,13 +1796,6 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
.map(DictBiz::getDictValue).findFirst().orElse(paymentMethod);
}
private String bfiProjectNumber(PaymentApplication entity, KingdeeSyncContext context) {
if (entity.getProjectId() == null) return KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE;
// 批量同步时按项目id缓存复用(同一项目只查一次),单笔同步退化为直接查询
ProjectApply project = context.projectCache().computeIfAbsent(entity.getProjectId(), projectApplyMapper::selectById);
return project == null || Func.isEmpty(project.getProjectCode()) ? KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE : project.getProjectCode();
}
/**
* 付款完成模拟(已停用):原在同步成功后模拟付款完成数据(paidAmount = 申请金额、MOCK-PAY- 前缀付款记录)。
* 真实付款结果现由定时回写任务 syncKingdeePaymentResult 经 A103-2/A103-4 查询回填驱动,
@@ -39,26 +39,39 @@ import org.springblade.system.pojo.entity.FeeItem;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveEntryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSavePreEntryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
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.thirdparty.bfi.constant.BfiConstant;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.CustomerArchiveMapper;
import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.FormalSettlementSourceMapper;
import org.springblade.transport.mapper.FormalSettlementSummaryFeeMapper;
import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.mapper.PaymentApplicationRecordMapper;
import org.springblade.transport.mapper.PreSettlementAdvanceMapper;
import org.springblade.transport.mapper.ProjectApplyMapper;
import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CustomerArchive;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.FormalSettlementDetailFee;
import org.springblade.transport.pojo.entity.FormalSettlementSource;
import org.springblade.transport.pojo.entity.FormalSettlementSummaryFee;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.entity.PaymentApplicationRecord;
import org.springblade.transport.pojo.entity.PreSettlementAdvance;
import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springframework.stereotype.Component;
@@ -73,6 +86,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
@@ -86,6 +100,10 @@ import java.util.stream.Collectors;
* + 结算合计费用项(手动添加的每行一条);
* 物料编码按装卸大类/项目业务模式/运单运输方式映射,编码维护在 Nacos 配置 thirdparty.bfi.material.*(见 {@link #resolveMaterialNumber});
* 运输类分录数量取运输量(按税率拆行时每行都取运输量)、装卸类默认 1;单价按结算金额/数量反算。</p>
* <p>预付信息分录(preentry,见 {@link #resolvePreEntries}):存在预付款抵扣(进度预付/项目预付核销)时,
* 按金蝶付款处理单(cas_paybill)逐笔送出 y_billtype/y_billid/y_settleamt,同 y_billid 合并求和;
* 任一笔应抵扣预付无法定位金蝶付款处理单内码(未付款/内码未回写/实付不足)时抛异常拦截应付单推送,
* 失败落 kingdee_fail_reason,付款结果回写后由重推任务自动续推。</p>
*
* @author Chill
*/
@@ -100,6 +118,8 @@ public class KingdeeApBillSyncSupport {
private static final int PAY_RESULT_PAGE_SIZE = 200;
/** 结算明细行(运费)计费方案匹配费用项(运输类无税率残差组标识用) */
private static final String FREIGHT_FEE_ITEM = "运输费";
/** 预付抵扣拦截提示最多列出的预付单笔数(超出部分汇总"等N笔",kingdee_fail_reason 列长 500 截断兜底) */
private static final int PREPAY_BLOCK_REASON_LIMIT = 3;
/** 费用大类编码(业务字典 fee_category 键值):运输 */
public static final String FEE_CATEGORY_FREIGHT = "A01";
/** 费用大类编码(业务字典 fee_category 键值):装卸 */
@@ -138,7 +158,12 @@ public class KingdeeApBillSyncSupport {
private final FormalSettlementDetailFeeMapper formalSettlementDetailFeeMapper;
private final FormalSettlementSummaryFeeMapper formalSettlementSummaryFeeMapper;
private final FormalSettlementMapper formalSettlementMapper;
private final FormalSettlementSourceMapper formalSettlementSourceMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
private final PaymentApplicationRecordMapper paymentApplicationRecordMapper;
private final PreSettlementAdvanceMapper preSettlementAdvanceMapper;
private final ProjectApplyMapper projectApplyMapper;
private final SettlementProjectAdvanceWriteOffMapper projectAdvanceWriteOffMapper;
private final ISysClient sysClient;
private final BfiProperties bfiProperties;
@@ -277,7 +302,9 @@ public class KingdeeApBillSyncSupport {
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
* 明细分录取页面结算明细(每行按 货物→费用大类→税率 拆分)+ 结算合计费用项(每行一条),结算金额逐行直送;
* 汇率日期/汇率取结算单页面维护值;付款计划 planentity 暂不传(启用方案见方法内注释),
* 当前由金蝶按明细自动汇总生成
* 当前由金蝶按明细自动汇总生成;
* 预付信息分录 preentry 仅在真正新建应付单时解析(幂等跳过路径不做预付校验),
* 每条对应一张金蝶付款处理单(cas_paybill),取数口径见 {@link #resolvePreEntries}
*
* @param apBillSrcCode 财务应付单防重键(正式结算单号_结算明细单据号拼接)
* @param settlement 正式结算单(合同/汇率日期/汇率取数来源)
@@ -293,7 +320,7 @@ public class KingdeeApBillSyncSupport {
String existBillNo = context.apBillNoCache().computeIfAbsent(apBillSrcCode, code ->
queryApBillExistNos(List.of(code)).getOrDefault(code, ""));
if (Func.isNotEmpty(existBillNo)) {
log.info("正式结算单[{}]金蝶财务应付单已生成(单据编号[{}]),幂等跳过 A102-2 保存", subject, existBillNo);
log.info("正式结算单[{}]金蝶财务应付单已生成(单据编号[{}]), 幂等跳过 A102-2 保存", subject, existBillNo);
return new BfiApBillPushOutcome(existBillNo, null, true);
}
ContractManage contract = requireContract(settlement.getContractId(), context);
@@ -355,12 +382,11 @@ public class KingdeeApBillSyncSupport {
param.setAwrpSrcSys(bfiProperties.getSrcSys());
// 【必填】外部系统来源单据号(候选键):正式结算单号_结算明细单据号1_单据号2...(复用预检已组装的防重键)
param.setAwrpSrcCode(apBillSrcCode);
// 来源单据(非必传,有上游合同时传 awrp_contract_info)
// param.setAwrpSrcBt(KingdeeBfiConstant.BFI_CONTRACT_SRC_BT);
// 上游外部系统单据号/上游合同号(非必传)
// if (Func.isNotEmpty(contract.getContractNo())) {
// param.setAwrpSrcConNo(contract.getContractNo());
// }
// 来源单据 + 上游合同号(非必传):开关开启且合同已同步金蝶(kingdee_id 非空)时送出,防上游合同勾稽挂空
if (bfiProperties.isContractRefEnabled() && Func.isNotBlank(contract.getContractNo()) && Func.isNotBlank(contract.getKingdeeId())) {
param.setAwrpSrcBt(KingdeeBfiConstant.BFI_CONTRACT_SRC_BT);
param.setAwrpSrcConNo(contract.getContractNo());
}
// 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE);
@@ -382,8 +408,12 @@ public class KingdeeApBillSyncSupport {
entry.setEPricetaxtotal(entryGroup.amount());
// 金额(不含税):结算明细行结算金额(不含税),结算合计费用项行无不含税金额不传
entry.setEAmount(entryGroup.amountNoTax());
// 【必填】项目.项目编码(联调期临时传金蝶测试档案值)
entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST);
// 税率(%):合同计费方案规则税率(按税率拆行维度),未配置税率/结算合计费用项行不送,金蝶按默认带出
entry.setTaxrate(entryGroup.taxRate());
// 税率编码(taxrateid_number):thirdparty.bfi.taxrate 配置 税率数值->编码,未维护该税率拦截报错
entry.setTaxrateidNumber(taxrateCode(entryGroup.taxRate()));
// 【必填】项目.项目编码:开关开启取项目档案真实编码(金蝶档案须已存在且编码一致),未关联项目兜底 999
entry.setProjectNumber(this.kingdeeProjectNumber(settlement.getProjectId(), context));
// 【必填】外部系统来源分录标识(候选键):结算明细行取单据号,结算合计费用项行取结算单号-费用类型-费用项拼接
entry.setAwrpEntryCode(entryGroup.entryCode());
// 分录备注(非必传)
@@ -396,22 +426,175 @@ public class KingdeeApBillSyncSupport {
// 付款计划 planentity 暂不传(联调待确认后启用):启用后送正式结算单下绑定、单据状态为审核通过(approved)
// 的付款信息,每条付款信息一行,awrp_entry_code_p 取付款单号(A103-1 下推 awrp_finapbill_entryid 勾稽此值);
// 当前不传由金蝶按明细自动汇总生成,A103-1 推送前经 A102-4 反查金蝶生成的 awrp_entry_code_p
// TODO 待业务确认:存在预付款抵扣时是否需传预付信息 preentry(y_billtype/y_billid/y_settleamt),本期不传、后续迭代
// 预付信息分录 preentry:存在预付款抵扣(进度预付/项目预付核销)时按金蝶付款处理单逐笔送出,
// 任一笔无法定位金蝶付款处理单内码(未付款/内码未回写/实付不足)时拦截推送(见 resolvePreEntries)
List<BfiApBillSavePreEntryDTO> preEntries = this.resolvePreEntries(settlement);
if (!preEntries.isEmpty()) {
param.setPreentry(preEntries);
}
// 推送入参摘要(联调排查:防重键、分录条数、页面维护的汇率口径)
log.info("推送金蝶财务应付单:正式结算单[{}],防重键[{}],明细分录{}条,汇率日期[{}],汇率[{}]",
subject, apBillSrcCode, detailentry.size(), param.getExratedate(), param.getExchangerate());
log.info("推送金蝶财务应付单: 正式结算单[{}], 防重键[{}], 明细分录{}条, 预付信息{}条, 汇率日期[{}], 汇率[{}]",
subject, apBillSrcCode, detailentry.size(), preEntries.size(), param.getExratedate(), param.getExchangerate());
BfiBillSaveResultVO saveResult = bfiService.saveApBill(param);
// 应付单id不落库,后续如需可经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询
requireBfiSaveSuccess(saveResult, "推送金蝶财务应付单");
String billNo = saveResult.getResult().get(0).getNumber();
log.info("金蝶财务应付单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, detailentry.size());
log.info("金蝶财务应付单保存成功: 正式结算单[{}], 单据编号[{}], 明细分录{}条", subject, billNo, detailentry.size());
// 星瀚应付单内码随结果返回(A902 附件上传定位用)
BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0);
return new BfiApBillPushOutcome(savedItem.getNumber(), savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false);
}
// ==================== 预付信息分录(A102-2 preentry,预付抵扣) ====================
/**
* 解析正式结算单预付信息分录(A102-2 preentry):每条对应一张金蝶付款处理单(cas_paybill),
* 项目预付取核销行核销金额、进度预付取预付记录已付金额,按付款处理单实付占比分摊后同 y_billid 合并求和。
* 任一笔应抵扣预付无法定位金蝶付款处理单(付款申请不存在/未付款/内码未回写/实付不足)时抛异常拦截应付单推送,
* 失败落 kingdee_fail_reason,付款结果回写后由重推任务自动续推
*
* @param settlement 正式结算单(应付)
* @return 预付信息分录列表(按 y_billid 合并后),无预付抵扣返回空列表
*/
private List<BfiApBillSavePreEntryDTO> resolvePreEntries(FormalSettlement settlement) {
// 应抵扣需求按底层付款申请归集:付款申请id -> (本次抵扣金额, 来源描述)
Map<Long, BigDecimal> demandAmounts = new LinkedHashMap<>();
Map<Long, String> demandSources = new LinkedHashMap<>();
// 项目预付:核销行核销金额即本次抵扣金额(核销保存校验已要求预付金蝶已付款)
List<SettlementProjectAdvanceWriteOff> writeOffs = this.projectAdvanceWriteOffMapper.selectList(
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, TransportBusiConstant.SETTLEMENT_BILL_FORMAL)
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlement.getId())
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0));
for (SettlementProjectAdvanceWriteOff writeOff : writeOffs) {
if (writeOff.getPaymentApplicationId() == null || this.money(writeOff.getWriteOffAmount()).signum() <= 0) {
continue;
}
demandAmounts.merge(writeOff.getPaymentApplicationId(), this.money(writeOff.getWriteOffAmount()), BigDecimal::add);
demandSources.putIfAbsent(writeOff.getPaymentApplicationId(), "项目预付[" + Func.toStr(writeOff.getPaymentNo()) + "]");
}
// 进度预付:来源预结算的预付记录已付金额即本次抵扣金额(syncOnePreSettlementAdvance 已按预结算关联行分摊)
List<FormalSettlementSource> sources = this.formalSettlementSourceMapper.selectList(
Wrappers.<FormalSettlementSource>lambdaQuery()
.eq(FormalSettlementSource::getFormalSettlementId, settlement.getId())
.eq(FormalSettlementSource::getIsDeleted, 0));
List<Long> preSettlementIds = sources.stream().map(FormalSettlementSource::getPreSettlementId)
.filter(Objects::nonNull).distinct().toList();
if (!preSettlementIds.isEmpty()) {
List<PreSettlementAdvance> advances = this.preSettlementAdvanceMapper.selectList(
Wrappers.<PreSettlementAdvance>lambdaQuery()
.in(PreSettlementAdvance::getPreSettlementId, preSettlementIds)
.eq(PreSettlementAdvance::getIsDeleted, 0)
.ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED));
// 预付记录 advanceNo 即付款申请单号,按单号批量定位底层付款申请(避免循环内逐单查询)
List<String> advanceNos = advances.stream().map(PreSettlementAdvance::getAdvanceNo)
.filter(Func::isNotBlank).distinct().toList();
Map<String, PaymentApplication> applicationsByNo = advanceNos.isEmpty() ? Map.of()
: this.paymentApplicationMapper.selectList(Wrappers.<PaymentApplication>lambdaQuery()
.in(PaymentApplication::getPaymentNo, advanceNos)
.eq(PaymentApplication::getIsDeleted, 0))
.stream().collect(Collectors.toMap(PaymentApplication::getPaymentNo, Function.identity(),
(first, second) -> first));
for (PreSettlementAdvance advance : advances) {
if (this.money(advance.getPaidAmount()).signum() <= 0) {
continue;
}
PaymentApplication application = applicationsByNo.get(advance.getAdvanceNo());
if (application == null) {
throw new ServiceException("推送金蝶财务应付单失败:预付单[" + Func.toStr(advance.getAdvanceNo())
+ "]不存在付款申请,无法解析预付信息");
}
demandAmounts.merge(application.getId(), this.money(advance.getPaidAmount()), BigDecimal::add);
demandSources.putIfAbsent(application.getId(), "进度预付[" + Func.toStr(advance.getAdvanceNo()) + "]");
}
}
if (demandAmounts.isEmpty()) {
// 无预付抵扣(无核销行且预付记录已付金额均为0):不送 preentry,保持报文字段缺省
log.info("应付单预付信息归集: 正式结算单[{}], 无预付抵扣, 不送预付信息分录", settlement.getFormalSettlementNo());
return List.of();
}
// 预付抵扣归集摘要(联调排查: 项目预付按核销行、进度预付按预付记录已付金额归集到底层付款申请)
log.info("应付单预付信息归集: 正式结算单[{}], 应抵扣付款申请{}笔, 合计金额{}",
settlement.getFormalSettlementNo(), demandAmounts.size(),
demandAmounts.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add));
// 批量取付款申请与付款处理记录(仅保留带回金蝶付款处理单内码的记录),逐笔校验并分摊
Map<Long, PaymentApplication> applications = this.paymentApplicationMapper.selectBatchIds(demandAmounts.keySet()).stream()
.collect(Collectors.toMap(PaymentApplication::getId, Function.identity(), (first, second) -> first));
Map<Long, List<PaymentApplicationRecord>> recordsByApplicationId = this.paymentApplicationRecordMapper.selectList(
Wrappers.<PaymentApplicationRecord>lambdaQuery()
.in(PaymentApplicationRecord::getPaymentApplicationId, demandAmounts.keySet())
.eq(PaymentApplicationRecord::getIsDeleted, 0))
.stream().filter(record -> Func.isNotBlank(record.getKingdeeBillId()))
.collect(Collectors.groupingBy(PaymentApplicationRecord::getPaymentApplicationId));
Map<String, BigDecimal> settleAmountsByBillId = new LinkedHashMap<>();
Map<String, String> sourcesByBillId = new LinkedHashMap<>();
List<String> blockReasons = new ArrayList<>();
demandAmounts.forEach((applicationId, demandAmount) -> {
String source = demandSources.get(applicationId);
PaymentApplication application = applications.get(applicationId);
if (application == null) {
blockReasons.add(source + "不存在付款申请");
return;
}
List<PaymentApplicationRecord> records = recordsByApplicationId.getOrDefault(applicationId, List.of());
if (records.isEmpty()) {
blockReasons.add(source + (KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(application.getKingdeeStatus())
? "金蝶付款处理单内码未回写,请先在付款管理执行[同步付款结果]后再推送应付单"
: "尚未付款完成,预付须金蝶付款完成后方可抵扣"));
return;
}
BigDecimal recordTotal = records.stream().map(record -> this.money(record.getPaidAmount()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (recordTotal.compareTo(demandAmount) < 0) {
blockReasons.add(source + "付款处理单实付金额不足");
return;
}
// 按付款处理单实付占比分摊(末条取剩余补差保证合计精确),同 y_billid 合并求和
BigDecimal remaining = demandAmount;
for (int index = 0; index < records.size(); index++) {
PaymentApplicationRecord record = records.get(index);
BigDecimal settleAmount = index == records.size() - 1 ? remaining
: demandAmount.multiply(this.money(record.getPaidAmount()))
.divide(recordTotal, 2, RoundingMode.HALF_UP)
.min(remaining);
settleAmount = settleAmount.setScale(2, RoundingMode.HALF_UP);
if (settleAmount.signum() <= 0) {
continue;
}
remaining = remaining.subtract(settleAmount);
settleAmountsByBillId.merge(record.getKingdeeBillId(), settleAmount, BigDecimal::add);
sourcesByBillId.putIfAbsent(record.getKingdeeBillId(), source);
}
});
if (!blockReasons.isEmpty()) {
String summary = blockReasons.size() > PREPAY_BLOCK_REASON_LIMIT
? String.join(";", blockReasons.subList(0, PREPAY_BLOCK_REASON_LIMIT)) + ";等" + blockReasons.size() + "笔预付无法抵扣"
: String.join(";", blockReasons);
// 拦截关键日志:原因落盘供排查(异常文案同步落 kingdee_fail_reason,付款结果回写后由重推任务自动续推)
log.warn("应付单预付信息校验未通过, 拦截应付单推送: 正式结算单[{}], 应抵扣付款申请{}笔, 原因: {}",
settlement.getFormalSettlementNo(), demandAmounts.size(), summary);
throw new ServiceException("推送金蝶财务应付单失败:" + summary);
}
List<BfiApBillSavePreEntryDTO> preEntries = new ArrayList<>();
settleAmountsByBillId.forEach((billId, settleAmount) -> {
BfiApBillSavePreEntryDTO preEntry = new BfiApBillSavePreEntryDTO();
preEntry.setYBilltype(BfiConstant.PREPAY_BILL_TYPE_PAY_BILL);
preEntry.setYBillid(billId);
preEntry.setYSettleamt(settleAmount);
preEntries.add(preEntry);
log.info("应付单预付信息分录: 正式结算单[{}], 来源[{}], y_billid={}, y_settleamt={}",
settlement.getFormalSettlementNo(), sourcesByBillId.get(billId), billId, settleAmount);
});
// 分录解析摘要:同一付款申请跨多预结算分摊后落在同一付款处理单的金额已按 y_billid 合并
log.info("应付单预付信息解析完成: 正式结算单[{}], 应抵扣付款申请{}笔, 生成分录{}条, 合计金额{}",
settlement.getFormalSettlementNo(), demandAmounts.size(), preEntries.size(),
preEntries.stream().map(BfiApBillSavePreEntryDTO::getYSettleamt)
.reduce(BigDecimal.ZERO, BigDecimal::add));
return preEntries;
}
// ==================== 结算明细分录解析(结算明细行 + 结算合计费用项) ====================
/**
@@ -490,7 +673,7 @@ public class KingdeeApBillSyncSupport {
+ "]无结算明细与结算合计费用项,无法解析分录");
}
// 分录解析摘要(联调排查:分录数=结算明细拆分行+手动添加的结算合计费用项,物料/分录标识逐条见下方分录生成日志)
log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条 + 手工结算合计{}条 = 共{}条分录",
log.info("金蝶应付单分录解析完成: 正式结算单[{}], 结算明细{}条 + 手工结算合计{}条 = 共{}条分录",
settlement.getFormalSettlementNo(), settlementDetails.size(), manualSummaryFees.size(), entryGroups.size());
return entryGroups;
}
@@ -535,7 +718,7 @@ public class KingdeeApBillSyncSupport {
context.taxRateByRule(), rowAmount);
if (splits.isEmpty()) {
// 0元货物行(费用项与结算金额均为0)跳过不生成0元分录
log.info("金蝶应付单分录生成:来源[{}],费用项与结算金额均为0,跳过该货物行", sourceDesc);
log.info("金蝶应付单分录生成: 来源[{}], 费用项与结算金额均为0, 跳过该货物行", sourceDesc);
return;
}
// 运输类数量:取运单运输量(未维护兜底 1)
@@ -561,9 +744,9 @@ public class KingdeeApBillSyncSupport {
// 分录唯一标识:单据号-货物序号-费用大类-税率(税率未配置不拼)
String entryCode = entryCodePrefix + "-" + split.getCategory().getLabel() + split.taxRateSuffix();
entryGroups.add(new BfiEntryGroup(materialNumber, entryCode, quantity, price, amount,
amountNoTax, cargoName, vehicleNo, context.settlement().getId()));
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]",
sourceDesc, entryCode, materialNumber, quantity, price, amount, amountNoTax);
amountNoTax, cargoName, vehicleNo, context.settlement().getId(), split.getTaxRate()));
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}],税率[{}]",
sourceDesc, entryCode, materialNumber, quantity, price, amount, amountNoTax, split.getTaxRate());
}
}
@@ -595,7 +778,7 @@ public class KingdeeApBillSyncSupport {
String entryCode = settlement.getFormalSettlementNo() + "-"
+ (Func.isBlank(feeItem) ? feeType : feeType + "-" + feeItem);
entryGroups.add(new BfiEntryGroup(materialNumber, entryCode, quantity, price, amount,
null, null, null, settlement.getId()));
null, null, null, settlement.getId(), null));
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}]",
sourceDesc, entryCode, materialNumber, quantity, price, amount);
}
@@ -654,6 +837,28 @@ public class KingdeeApBillSyncSupport {
return text != null && text.contains("国际");
}
/**
* 金蝶税率编码(taxrateid_number):thirdparty.bfi.taxrate 配置 税率数值 -> 编码(如 9 -> V9),
* 税率为空(无税率组/手动结算合计费用行)返回 null 不送;配置未维护该税率时直接拦截报错
* (与物料映射拦截口径一致),税率数值键去除尾零精确匹配(9.00 按 "9" 匹配)
*
* @param taxRate 分录税率(百分数,来自合同计费方案规则)
* @return 金蝶税率.编码,无税率为 null
*/
private String taxrateCode(BigDecimal taxRate) {
if (taxRate == null) {
return null;
}
String rateKey = taxRate.stripTrailingZeros().toPlainString();
String code = bfiProperties.getTaxrate() == null ? null : bfiProperties.getTaxrate().get(rateKey);
if (Func.isBlank(code)) {
throw new ServiceException("推送金蝶财务应付单失败:税率[" + rateKey + "%]未在配置 thirdparty.bfi.taxrate 中维护税率编码,请补充后重试");
}
// 税率编码映射判定依据(联调排查:税率 -> 金蝶税率档案编码)
log.info("金蝶税率编码映射: 税率[{}%] -> 编码[{}]", rateKey, code);
return code.trim();
}
/**
* 合同计费方案规则税率解析:费用类型/费用项 -> 税率(取首个配置了税率的规则,
* 计费方案 JSON 解析失败按未配置处理,由税率分组处兜底为无税率组)
@@ -695,7 +900,7 @@ public class KingdeeApBillSyncSupport {
} catch (RuntimeException ignored) {
// 计费方案 JSON 兼容历史数据,解析失败按未配置处理
}
log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置税率{}", contract.getContractNo(), taxRateByRule.size(), taxRateByRule);
log.info("合同[{}]计费方案解析: {}项费用类型/费用项配置税率{}", contract.getContractNo(), taxRateByRule.size(), taxRateByRule);
return taxRateByRule;
}
@@ -948,7 +1153,7 @@ public class KingdeeApBillSyncSupport {
}
loaded.putIfAbsent(feeItem.getName().trim(), feeItem.getFeeCategory().trim());
}
log.info("费用项档案加载:{}项(费用项名称 -> 费用大类编码)", loaded.size());
log.info("费用项档案加载: {}项(费用项名称 -> 费用大类编码)", loaded.size());
cache.putAll(loaded);
return cache;
}
@@ -1148,6 +1353,26 @@ public class KingdeeApBillSyncSupport {
return config.getKingdeeDeptCode().trim();
}
/**
* 金蝶项目编码:开关 thirdparty.bfi.project-real-code-enabled 开启时取项目档案
* blade_project_apply.project_code 真实值(金蝶项目档案须已存在且编码一致,否则该单推送失败),
* 项目未关联/编码为空兜底 999;开关关闭维持联调临时值 999。
* 财务应付/应收分录、付款申请、开票申请单项目编码统一走本方法,批量同步按项目id缓存复用
*
* @param projectId 单据关联项目id
* @param context 金蝶同步上下文(缓存载体)
* @return 金蝶项目编码
*/
public String kingdeeProjectNumber(Long projectId, KingdeeSyncContext context) {
if (!bfiProperties.isProjectRealCodeEnabled() || projectId == null) {
return KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE;
}
// 批量同步时按项目id缓存复用(同一项目只查一次),单笔同步退化为直接查询
ProjectApply project = context.projectCache().computeIfAbsent(projectId, projectApplyMapper::selectById);
return project == null || Func.isBlank(project.getProjectCode())
? KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE : project.getProjectCode();
}
/** 金蝶组织/部门编码共用校验:项目存在且已维护业务部门 */
private ProjectApply requireOrgProject(Long projectId, KingdeeSyncContext context) {
if (projectId == null) {
@@ -1255,10 +1480,11 @@ public class KingdeeApBillSyncSupport {
* @param transportGoods 运输货物(金蝶接口新字段 awrp_transport):结算明细货物名称,结算合计费用项行为 null 不传
* @param vehicleNo 车号(金蝶接口新字段 awrp_lpn):结算明细车号,结算合计费用项行为 null 不传
* @param formalSettlementId 分录所属正式结算单id(多结算单付款申请 A103-1 按结算单勾稽各自应付单)
* @param taxRate 该行税率(%)(合同计费方案规则税率,按税率拆行维度),无税率组/结算合计费用项行为 null 不传
*/
public record BfiEntryGroup(String materialNumber, String entryCode,
BigDecimal quantity, BigDecimal price, BigDecimal amount, BigDecimal amountNoTax,
String transportGoods, String vehicleNo, Long formalSettlementId) {
String transportGoods, String vehicleNo, Long formalSettlementId, BigDecimal taxRate) {
}
/**
@@ -256,7 +256,11 @@ public class KingdeeArBillSyncSupport {
param.setAwrpSrcSys(bfiProperties.getSrcSys());
// 【必填】外部系统来源单据号(候选键):正式结算单号_结算明细单据号1_单据号2...(复用预检已组装的防重键)
param.setAwrpSrcCode(arBillSrcCode);
// 来源单据(非必传,有上游合同时传 awrp_contract_info),当前与应付单保持一致不传
// 来源单据 + 上游合同号(非必传):开关开启且合同已同步金蝶(kingdee_id 非空)时送出,与应付单同口径
if (bfiProperties.isContractRefEnabled() && Func.isNotBlank(contract.getContractNo()) && Func.isNotBlank(contract.getKingdeeId())) {
param.setAwrpSrcBt(KingdeeBfiConstant.BFI_CONTRACT_SRC_BT);
param.setAwrpSrcConNo(contract.getContractNo());
}
// 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_AR_BILL_SRC_TYPE);
@@ -289,8 +293,8 @@ public class KingdeeArBillSyncSupport {
row.setERecamount(entryGroup.amount());
// 金额(不含税):结算明细行结算金额(不含税),结算合计费用项行无不含税金额不传
row.setEAmount(entryGroup.amountNoTax());
// 【必填】项目.项目编码(联调期临时传金蝶测试档案值)
row.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST);
// 【必填】项目.项目编码:开关开启取项目档案真实编码(金蝶档案须已存在且编码一致),未关联项目兜底 999
row.setProjectNumber(kingdeeApBillSyncSupport.kingdeeProjectNumber(settlement.getProjectId(), context));
// 【必填】外部系统来源分录标识(候选键):结算明细行取单据号,结算合计费用项行取结算单号-费用类型-费用项拼接
row.setAwrpEntryCode(entryGroup.entryCode());
// 分录备注(非必传)
@@ -152,21 +152,19 @@ public class KingdeeReceivedInvoiceSyncSupport {
/**
* 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。
* 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位;
* 明细分录与财务应付单 A102-2 同源同口径:结算明细按 货物→费用大类→税率 拆分行+手动添加结算合计费用项每行一条,物料/数量/单价/金额取自
* 与应付单分录同一解析结果({@link KingdeeApBillSyncSupport.BfiEntryGroup}),分录标识同键。
* 主表:组织/币种/往来户编码按收票登记所属合同派生(与应付单同源),销方=开票单位、购方=受票单位;
* 明细分录取发票明细行(镜像 items_json,A801-1),标识同键。
* 联调口径沿用应付侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码),
* 非必填的税号/地址/电话/开户行等 setter 注释保留,联调确认后放开
*
* @param receipt 收票登记(审批通过、已绑定镜像)
* @param mirror 票池镜像(票面主表字段取数来源)
* @param contract 分摊应付结算单关联合同(组织/币种/往来户派生)
* @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果)
* @param context 金蝶同步上下文(缓存载体)
* @param receipt 收票登记(审批通过、已绑定镜像)
* @param mirror 票池镜像(票面主表字段取数来源)
* @param contract 收票登记所属合同(组织/币种/往来户派生,结算组织=合同承办部门对应平台公司)
* @param context 金蝶同步上下文(缓存载体)
* @return 推送结果(单据编号 + 星瀚收票单内码,A902 附件上传定位用)
*/
public ReceivedInvoicePushOutcome pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract,
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups, KingdeeSyncContext context) {
KingdeeSyncContext context) {
if (Func.isBlank(mirror.getInvoiceTypeCode())) {
throw new ServiceException("推送金蝶收票单失败:镜像缺少票易通发票类型编码,请先重新绑定票池发票");
}
@@ -98,10 +98,14 @@ public class BfiApBillSaveEntryDTO implements Serializable {
*/
private Boolean ispresent = Boolean.FALSE;
/**
* 税率.编码,城矿传 V13(BfiConstant.TAX_RATE_V13)
* 税率.编码,按 thirdparty.bfi.taxrate 配置的 税率数值-&gt;编码 映射取值(如 9 -&gt; V9),未维护该税率拦截报错
*/
@JsonProperty("taxrateid_number")
private String taxrateidNumber;
/**
* 税率(%),取合同计费方案规则税率数值(如 9 表示 9%),未取到税率不送
*/
private BigDecimal taxrate;
/**
* 核心单据行号
*/
@@ -16,6 +16,7 @@
| 2026-10-31 | ① 收票登记改「先票后款」:收票登记只选票推金蝶收票单(不再关联结算单,新增手选结算组织/发票明细),「认领收票单+指定应付」移到付款结算单;② 收款流水与认领改口径:数据源改金蝶收款单(A301-1)、「推送金蝶」「查询金蝶认领结果」按钮下线、认领只登记本地、作废同步合同合规;③ 客商建档新增供应商编码自动获取与收款信息开户行带出联行号操作;④ 准备事项、定时任务清单、FAQ、速查表同步更新 |
| 2026-11-03 | **四文档汇总修订(合并《2026-10-17 结算调整单》《2026-10-18 预付与项目预付》两本手册 + 按 11-01/11-02 代码与界面更新)**:① 全篇菜单更名对齐系统实际(预付结算单/付款结算单/收款结算单,新增 1.4 页面名称对照);② 1.1 状态表补页面显示别名与「已指定应付」列;③ 新增 4.5 进度预付、4.6 项目预付(含客商风险与付款特批)、4.7 项目预付核销、4.8 尾款结算、4.12 结算调整单、4.16 作废与撤销;④ 开票申请按「剩余可开票金额」累计控制(放开必须未开票限制)、分摊发票金额系统自动分摊;⑤ **收票后款认领按 11-02 改造更新**:发票登记移到付款结算单工具栏、弹窗回显已认领收票单、「匹配结算单金额」支持手动修改、查看页新增「是否已指定应付」列与「重新推送」入口、列表指定失败悬浮展示原因;⑥ **A302-6 指定应付定时自动补偿改开关控制(默认关闭)改人工兜底**;⑦ 4/5/6 节同步更新,截图全面更新(付款结算单列表与发票登记弹窗、选择收票单、查看页发票信息、指定失败悬浮、付款管理已指定应付列、收票登记表单去税率) |
| 2026-11-04 | **合并合同系统(HCS)操作内容,形成金蝶+合同系统组合手册(并入《HCS合同管理操作手册(纯操作版)》《履约计划与履约记录四场景业务说明》《客商黑名单与风险等级业务说明》)**:① **新增第 2 节「全链路总览」**(**12 条环节流程链**【系统】→步骤→▼ 格式逐环节梳理、两条通用规则与三条先后依赖;场景分流/拦截提示原文/成功提示在 4.x 对应小节体现),原第 2–6 节顺延为第 3–7 节,日常操作小节相应重排为 4.1–4.18;② 新增 1.5–1.8(HCS 是什么与合规状态、特批状态、三条推送路径、客商风险与黑名单口径),1.4 页面对照补客商/项目/合同顺序;③ 第 3 节新增合同系统准备清单;④ **项目立项推合规、合同管理与推送合规(含合同特批/变更/归档)置于 4.2/4.3(客商章节后)**;付款特批操作细节不单独成节(`spec-pay.enabled` 开关口径保留在 4.6);⑤ 新增 4.17 客商风险查看与筛选、4.18 履约计划与履约记录四场景;⑥ 4.6/4.10/4.14 补合同系统联动与金蝶实付门禁口径;⑦ 第 5 节新增 5.5 HCS 定时任务与开关及**六任务逐项运转逻辑**(判重/更新分流、特批兜底查询、金蝶实付门禁、开票自愈、收票合同回溯、风险分批刷新);⑧ 第 6/7 节补 HCS 常见问题与操作入口;⑨ 合同支持甲乙丙多方客商(2026-11-02)已写入 4.3;⑩ 新增截图 41/42/43(付款结果回写列表效果,FKD2026110300003↔FKSQ-000000282 对照),引用 HCS 手册现有截图 |
| 2026-11-05 | **按 11-04/11-05 代码修订**:① **4.9 应付单推送新增 preentry 预付抵扣分录**(进度预付已付额/项目预付核销额→金蝶付款处理单,自动合并同单;未付款/内码未回写/实付不足拦截整单,付款管理「同步付款结果」后自动续推;历史已推单不补);② **应付单分录送税率与税率编码**(Nacos `thirdparty.bfi.taxrate` 映射,未维护拦截);③ 第 3 节准备清单加 税率编码映射/推送内容开关(项目编码真实化 `project-real-code-enabled`、合同引用 `contract-ref-enabled`,默认关)/金蝶接口超时(读超时默认 120 秒),「金蝶组织编码」术语更名「金蝶公司代码」;④ 4.10 付款记录回写金蝶付款处理单内码说明;⑤ 「已指定应付」列范围修正:仅付款结算单列表与付款管理列表显示,**收款结算单不显示** |
---
@@ -46,7 +47,7 @@
| assigned | 已指定应付 | 已指定应付(终态,收票——由付款结算单侧认领并在应付单推送后指定) | 不变 |
| exist | 金蝶已有该客商且审核通过 | 金蝶已审核(终态,客商) | 不变 |
结算单列表、付款管理列表还有一列「**已指定应付**」(聚合本单认领收票单的指定状态):**已指定应付**(绿)/ **指定失败**(红,悬浮展示聚合的各票失败原因)/ **未指定**(灰)/ `-`(本单未认领收票单)。
**付款结算单**列表、付款管理列表还有一列「**已指定应付**」(聚合本单认领收票单的指定状态):**已指定应付**(绿)/ **指定失败**(红,悬浮展示聚合的各票失败原因)/ **未指定**(灰)/ `-`(本单未认领收票单)。**收款结算单不显示该列**(指定应付仅应付侧参与)。
### 1.2 「金蝶锁定」标签是什么
@@ -255,7 +256,8 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
│ ① 审批通过;外币先重选汇率日期带出结算汇率(预付结算单汇率不自动继承)
▼
推送(审批自动开关 / 工具栏「同步金蝶」)
│ ② 应付 A102-2 / 应收 A302-2 按结算类型自动分流
│ ② 应付 A102-2 / 应收 A302-2 按结算类型自动分流;应付单自动带预付抵扣分录
│ (preentry:进度预付已付额/项目预付核销额→对应金蝶付款处理单,未回写内码拦截)与税率分录
│ ③ 成功:金蝶单据号回填 + 状态已同步 + 金蝶锁定(不可改/不可作废)
│ ④ 应付单生成后,系统自动对本单认领的收票单执行「指定应付」(见⑩)
▼
@@ -363,7 +365,7 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
| # | 事项 | 操作人 | 操作内容 | 不做的后果 |
|---|---|---|---|---|
| 1 | 部门金蝶组织编码 | 管理员 | 在部门档案维护「金蝶组织编码」字段 | 该部门单据推送时直接报错拦截 |
| 1 | 部门金蝶公司代码 | 管理员 | 在组织/部门档案维护「**金蝶公司代码**」字段(原金蝶组织编码) | 该部门单据推送时直接报错拦截 |
| 2 | 客商收款账户联行号 | 财务 | 客商档案维护收款账户(账号/开户行,**联行号必填**) | 付款推送、客商推送报错拦截 |
| 3 | 主数据初始化 SQL | 运维 | 执行 `doc/sql/transport/` 下金蝶相关脚本(计量单位等主数据、认领表、10-13 迁移脚本 `lqj/`) | 对应链路数据缺失 |
| 4 | 平台公司金蝶组织编码 | 管理员 | 组织管理维护「是否平台公司=是」并填**金蝶编码** | 收款流水同步直接报错拦截;收票按手选部门上溯平台公司取组织编码失败也会推送拦截 |
@@ -371,6 +373,9 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
| 6 | 物料映射 | 运维 | Nacos 配 `thirdparty.bfi.material.*`(有默认值,建议显式确认) | 推送报「运输方式未配置」类拦截 |
| 7 | 定时任务开关 | 运维 | 按第 5 节清单逐项开启 | 只能全靠手动按钮 |
| 8 | 审批自动同步开关 | 运维 | 按第 5 节 `autoSync.*` 按域开启 | 审批通过后不自动推送,走手动 |
| 9 | 税率编码映射 | 运维 | Nacos `thirdparty.bfi.taxrate.*`:税率数值→金蝶税率.编码(默认 0/1/3/6/9/13→V0…V13,可加小数税率如 `"[6.5]"`) | 应付单分录带该税率时推送直接拦截报错 |
| 10 | 推送内容开关(默认全关) | 运维 | `project-real-code-enabled`(项目编码取真实 `project_code`,前提金蝶项目档案编码一致,未关联项目兜底 999);`contract-ref-enabled`(应付/应收送上游合同号、开票送合同三件套) | 关闭时维持原口径(项目编码送 999、不送合同引用) |
| 11 | 金蝶接口超时 | 运维 | `connect-timeout-seconds`(默认 10)/`read-timeout-seconds`(默认 **120**,BFI 接口较慢勿调回小值) | 大单推送超时失败率上升 |
**合同系统(HCS)准备**(Nacos 前缀 `thirdParty.hcs.*`,与上表金蝶准备相互独立):
@@ -648,12 +653,18 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
1. **自动路径**:审批「通过」时自动推送(开关开启时)。
2. **手动路径**:勾选一条或多条单据 → 工具栏「同步金蝶」。
- 应付单(A102-2)/ 应收单(A302-2)按结算类型自动分流,无需选择。
- **推送时系统自动附带**(无需操作):① 明细分录带**税率与税率编码**(按第 3 节 #9 映射,未维护的税率直接拦截);② 应付单带**预付抵扣分录(preentry)**——把本单抵扣的进度预付(按已付金额)与项目预付核销金额逐笔登记到对应金蝶付款处理单,同一处理单自动合并;③ 开关开启时项目编码送真实编码、送上游合同号(第 3 节 #10)。
3. 成功标志:列表出现**金蝶单据号**,状态变 **已同步**,并出现「金蝶锁定中」标签。
4. 失败处理:状态变 **同步失败** → 鼠标悬浮状态标签看失败原因 → 常见原因:
4. 失败处理:状态变 **同步失败** → 鼠标悬浮状态标签看失败原因 → 常见原因与解决:
- 「请先同步金蝶应付单」类 → 关联结算单还没推(付款/开票依赖结算单先推,见 4.10/4.11);
- 客商编码取不到 → 先完成 4.1;
- 部门金蝶组织编码缺失 → 找管理员维护。
- 部门金蝶公司代码缺失 → 找管理员在组织/部门档案维护(第 3 节 #1);
- 税率未维护映射类 → 运维在第 3 节 #9 税率映射里补该税率后重推;
- 「预付单[XXX]尚未付款完成,预付须金蝶付款完成后方可抵扣」→ 等该预付金蝶付款完成(4.10 回写)后自动续推;
- 「预付单[XXX]金蝶付款处理单内码未回写,请先在付款管理执行【同步付款结果】后再推送应付单」→ 到付款管理勾选该预付执行「同步付款结果」,内码回写后下一轮自动重推成功;
- 「预付单[XXX]付款处理单实付金额不足」→ 核对付款处理单实付金额与抵扣额,异常时联系运维核对 A103-4 回写数据。
5. 纠偏:如果怀疑推送实际成功但本地状态没更新,等定时勾稽(或联系运维手动触发),系统会自动核对金蝶侧并纠正状态/补单号。
6. 注意:**历史已推送的应付单不会补 preentry**(幂等预检直接返回);preentry 相关失败会落 failed 与原因,修复前置后由 failed 重推自动续推成功。
推送成功后金蝶侧效果(供确认推送结果时对照):
@@ -677,7 +688,7 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
3. 查付款结果:
- 正常等定时任务回写(默认每 10 分钟,开关开启时);
- 急用点工具栏「**同步付款结果**」(勾选单据后显示;若未见到该按钮,说明当前环境以定时回写为主,等下一轮即可)。
4. 结果状态流转:已生成 → **付款中** → **已付款**(含付款记录/凭证信息)/ **已关闭**(关闭)。
4. 结果状态流转:已生成 → **付款中** → **已付款**(含付款记录/凭证信息)/ **已关闭**(关闭)。付款记录同步回写**金蝶付款处理单内码**(供 4.9 应付单预付抵扣分录 preentry 引用)。
5. 失败与特殊场景:
- **生成失败** → 鼠标悬浮状态标签看失败原因,修复后重推(批量同步按钮即可,系统会自动跳过金蝶已有的);系统每 10 分钟也会自动重推。
- 单据在金蝶被删:回调后自动置生成失败并清单号,直接重推;
@@ -0,0 +1,195 @@
# 财务应付单预付信息 preentry 推送金蝶设计
> 范围:正式结算单「同步金蝶」推送财务应付单(A102-2)时,携带预付信息 `preentry`(`y_billtype / y_billid / y_settleamt`)的取数口径、每笔计算规则、拦截规则与落地改动。
> 背景:`KingdeeApBillSyncSupport.pushApBill` 现有 TODO"存在预付款抵扣时是否需传预付信息 preentry,本期不传、后续迭代",本文为该迭代的实现设计。
> 关联文档:`2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md`。
> 设计日期:2026-11-04。
---
## 一、字段语义与结论
`preentry` 是 A102-2 财务应付单的预付信息分录,**支持多条**,用于登记本张应付单抵扣了哪些已付款的预付单。每条 `preentry` 对应**一张金蝶付款处理单(`cas_paybill`)**:
```json
"preentry": [
{ "y_billtype": "cas_paybill", "y_billid": "付款处理单1 id", "y_settleamt": 10000.00 },
{ "y_billtype": "cas_paybill", "y_billid": "付款处理单2 id", "y_settleamt": 5000.00 }
]
```
### 字段取值
| 字段 | 必填 | 取值 | 说明 |
| --- | --- | --- | --- |
| `y_billtype` | 是 | 固定 `cas_paybill`(`BfiConstant.PREPAY_BILL_TYPE_PAY_BILL`) | TMS 预付均经 A103-3 付款申请 → 金蝶付款处理,不产生期初预付单(`ap_paidbill` 不使用) |
| `y_billid` | 是 | 金蝶**付款处理单** `id`(A103-4 `BfiPayBillVO.id`) | **不能**用 `blade_payment_application.kingdee_bill_id`(那是 A103-2 返回的付款申请单 id,类型不对) |
| `y_settleamt` | 否 | 本次应付单实际抵扣该预付的金额,2 位小数 | 进度预付取 `PreSettlementAdvance.paid_amount`,项目预付取核销行 `write_off_amount` |
### 两个易混淆字段(不在本次改造范围)
| 字段 | 取值 | 现状 |
| --- | --- | --- |
| `awrp_src_type`(A102-2 应付单头) | `TMS财务应付单` | `KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE`,保持不变,**不要**改成 `0KzXs` |
| `awrp_src_type`(A103-1/A103-3 付款申请头/分录) | 固定 `0KzXs` | Nacos `thirdParty.bfi.payApply.srcType` 配置,已是正确值,无需改 |
---
## 二、现状核查
### 1. preentry 目前未送
`KingdeeApBillSyncSupport.pushApBill`(A102-2 保存)明细分录之后保留 TODO,`setPreentry` 全项目无调用;DTO `BfiApBillSavePreEntryDTO`(`y_billtype / y_billid / y_settleamt`)已就绪,`@JsonInclude(NON_NULL)`——**为空时保持 `null`,不要设置空数组**(避免报文送 `preentry: []`)。
### 2. y_billid 需要的内码未落库
| 数据 | 实际值 | 可否作 `y_billid` |
| --- | --- | --- |
| `blade_payment_application.kingdee_bill_id` | A103-2 返回的付款申请单 id(`ap_payapply`) | ❌ 类型不对 |
| `blade_payment_application_record.kingdee_bill_no` | A103-4 返回的付款处理单号(`cas_paybill.billno`) | ❌ 只有单号 |
| `blade_payment_application_record.voucher_json` | A103-4 付款处理单整单 JSON,含 `id` | ⚠️ 仅 JSON 内有,无独立字段 |
| `blade_pre_settlement_advance.kingdee_advance_no` | 付款申请单号 | ❌ |
| `blade_settlement_project_advance_write_off.kingdee_bill_no` | 付款申请单号 | ❌ |
**结论:必须先补存 `cas_paybill.id`**,否则 preentry 无值可送。
---
## 三、每笔 preentry 的计算规则
### 粒度与合并
- 一条 `preentry` = 一张金蝶付款处理单(按 `y_billid` 唯一)。
- 同一 `y_billid` 被多行业务数据引用(如一张付款申请跨多个预结算分摊后落到同一张付款处理单)时,**按 `y_billid` 汇总金额只出一条**,避免向金蝶送重复键。
### 项目预付
数据源 `blade_settlement_project_advance_write_off`(本身就是"本结算单核销了某张项目预付多少"):
```
遍历核销行(settlement_type='formal' AND settlement_id=本单id)
→ payment_application_id → 付款申请
→ 付款处理记录(blade_payment_application_record,kingdee_bill_id 非空)
→ 应抵扣金额 = write_off_amount,按付款处理单实付分摊
→ 每条记录生成一条 preentry
```
### 进度预付
数据源 `blade_formal_settlement_source` + `blade_pre_settlement_advance`(`syncOnePreSettlementAdvance` 已把付款申请按预结算关联行拆分,`paid_amount` 即本预结算分摊额):
```
遍历正式结算来源(formal_settlement_id=本单id)
→ pre_settlement_id
→ 预付记录(pre_settlement_id= ? AND paid_amount>0 AND is_deleted=0)
→ advance_no(=付款申请单号)→ 付款申请
→ 付款处理记录(kingdee_bill_id 非空)
→ 应抵扣金额 = advance.paid_amount,按付款处理单实付分摊
→ 每条记录生成一条 preentry
```
### 多付款处理单分摊(部分付款兜底)
一张付款申请可能对应多张付款处理单(A103-4 多条回写):
1. 按各记录 `paid_amount` 占比分摊应抵扣金额;
2. 金额 `setScale(2, HALF_UP)`;
3. 最后一条取"总额 − 已分摊"补差,保证合计不差分;
4. 单条 `y_settleamt` 不超过该记录 `paid_amount`;
5. 应抵扣金额超过付款处理单实付合计时封顶为实付合计(正常业务不会出现,防御性处理)。
---
## 四、拦截规则(已确认:拦截应付单推送)
拦截点在 **A102-4 幂等预检(确认金蝶无本单应付单)之后、`saveApBill` 之前**;预检命中"已存在"直接幂等返回,不做校验(已推单据无法补 preentry)。
任一笔应抵扣预付不满足以下条件即抛 `ServiceException`,整张应付单不推:
| # | 校验 | 不满足提示 |
| --- | --- | --- |
| 1 | 底层付款申请存在 | 预付单[XXX]不存在付款申请 |
| 2 | 付款申请已付款(`kingdee_status='paid'` / 已有付款处理记录) | 预付单[XXX]尚未付款完成,预付须金蝶付款完成后方可抵扣 |
| 3 | `kingdee_bill_id` 非空(至少一条付款处理记录带回内码) | 预付单[XXX]金蝶付款处理单内码未回写,请先在付款管理执行【同步付款结果】后再推送应付单 |
| 4 | 付款处理单实付合计 ≥ 本次抵扣金额 | 预付单[XXX]付款处理单实付金额不足 |
提示信息**汇总所有不满足的预付单**,最多列 3 笔 + "等 N 笔",控制在 `kingdee_fail_reason` 的 500 字符内。
失败后的自动流转(复用现有机制,无需新增代码):
```
doSyncKingdee 抛 ServiceException
→ syncKingdee 捕获 → markKingdeeSyncFailed(failed + 失败原因留档)
→ retryKingdeeFailed 定时重推
→ A103-4 回写 kingdee_bill_id 后,下一轮重推自动成功
```
---
## 五、落地改动清单
### 1. 数据表与实体
- `blade_payment_application_record` 增加 `kingdee_bill_id varchar(64) DEFAULT NULL COMMENT '金蝶付款处理单id(cas_paybill.id,A103-4回写)'`;
- `PaymentApplicationRecord` 增加 `kingdeeBillId` 字段;
- SQL 放 `doc/sql/transport/lqj/temp/`,**可重复执行**,含存量回填:
```sql
ALTER TABLE `blade_payment_application_record`
ADD COLUMN `kingdee_bill_id` varchar(64) DEFAULT NULL COMMENT '金蝶付款处理单id(cas_paybill.id,A103-4回写)' AFTER `kingdee_bill_no`;
UPDATE `blade_payment_application_record`
SET `kingdee_bill_id` = JSON_UNQUOTE(JSON_EXTRACT(`voucher_json`, '$.id'))
WHERE `kingdee_bill_id` IS NULL AND `voucher_json` IS NOT NULL;
```
### 2. A103-4 回写
`PaymentApplicationServiceImpl.applyPayBillRecord` 补一行:
```java
record.setKingdeeBillId(payBill.getId() == null ? null : String.valueOf(payBill.getId()));
```
### 3. preentry 解析(KingdeeApBillSyncSupport)
- `KingdeeApBillSyncSupport` 追加注入 5 个 Mapper:`FormalSettlementSourceMapper`、`PreSettlementAdvanceMapper`、`SettlementProjectAdvanceWriteOffMapper`、`PaymentApplicationMapper`、`PaymentApplicationRecordMapper`;
- 新增私有方法 `resolvePreEntries(FormalSettlement settlement)`:按第三章两条链路取数 → 逐笔校验(第四章拦截规则)→ 分摊 → 按 `y_billid` 合并,返回 `List<BfiApBillSavePreEntryDTO>`;
- `pushApBill` 中 A102-4 预检通过后、保存前调用:
```java
List<BfiApBillSavePreEntryDTO> preEntries = this.resolvePreEntries(settlement);
if (!preEntries.isEmpty()) {
param.setPreentry(preEntries);
}
```
同时删除原 TODO 注释。
### 4. 日志
每笔输出:来源(进度预付/项目预付)、`y_billid`、`y_settleamt`;跳过/封顶/拦截输出原因。日志禁用中文标点。
---
## 六、边界与风险
| # | 边界 | 处理 |
| --- | --- | --- |
| 1 | 历史已推送的应付单 | A102-4 预检命中幂等返回,**不会补 preentry**(现有设计,不处理) |
| 2 | 预付未付款(synced/paying)但有抵扣金额 | 按拦截规则 #2 阻断,等付款完成后重推自动成功 |
| 3 | 进度预付部分付款 | 按已付金额(`paid_amount`)送,未付部分不抵扣;如业务后续要求"全额付清才可推应付单",在拦截规则追加 `paid_amount >= applied_amount` |
| 4 | 存量付款记录无 `kingdee_bill_id` 且 `voucher_json` 缺失 | 拦截规则 #3 阻断,走付款管理【同步付款结果】重新回写 |
| 5 | preentry 合计超应付单金额 | 正常业务不会出现(核销/预付均封顶于结算金额);防御性不额外校验,依赖金蝶侧报错 |
| 6 | `ap_paidbill`(期初预付单) | TMS 不产生,不在本期范围;如有手工核销需求由金蝶侧人工处理 |
---
## 七、验证要点
1. 无预付抵扣的应付单:报文无 `preentry` 字段(非空数组)。
2. 单笔项目预付核销 + 已付款:一条 preentry,`y_settleamt` = 核销额。
3. 进度预付跨多个预结算分摊:同 `y_billid` 合并为一条,金额为各行之和。
4. 预付未付款时推应付单:拦截、落 `failed` 与原因;付款完成后定时重推成功且带 preentry。
5. 部分付款(多条付款处理记录):分摊合计 = 应抵扣金额,末条补差无分差。
6. 存量数据回填:SQL 重复执行幂等,回填后无需重新同步付款结果。
+17
View File
@@ -162,6 +162,12 @@ thirdParty:
# 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS)
srcSys: ${BFI_SRC_SYS:WLTMS}
# 项目编码真实化开关:开启后推送金蝶的项目编码取 blade_project_apply.project_code 真实值
# (前提:金蝶项目档案已存在且编码与 project_code 一致,否则该单推送失败回写单据可重推;未关联项目兜底 999)
projectRealCodeEnabled: ${BFI_PROJECT_REAL_CODE_ENABLED:false}
# 合同引用开关:开启后财务应付/应收送上游合同号(仅 kingdee_id 非空的已同步合同)、开票申请送合同三件套(contractdate 取申请日期)
contractRefEnabled: ${BFI_CONTRACT_REF_ENABLED:false}
# 金蝶黑名单查询(A801-5 客商提审拦截):禁止交易类(A)始终拦截;
# 风险警示类(B)默认放行告警,开启后一并拦截
blacklist:
@@ -184,6 +190,17 @@ thirdParty:
# 仓储大类、手动添加仓储费用物料编码(仓储)
warehouse: ${BFI_MATERIAL_WAREHOUSE:XDWL11002001}
# 金蝶税率编码映射(A102-2 财务应付单分录 taxrateid_number):税率数值 -> 税率.编码(如 9 -> V9),
# 键为税率数值文本(含小数税率如 "[6.5]"),未维护该税率时推送拦截报错;
# 分录无税率(计费方案未配置税率组/残差组/手动结算合计费用行)时税率与税率编码均不送
taxrate:
"[0]": ${BFI_TAXRATE_0:V0}
"[1]": ${BFI_TAXRATE_1:V1}
"[3]": ${BFI_TAXRATE_3:V3}
"[6]": ${BFI_TAXRATE_6:V6}
"[9]": ${BFI_TAXRATE_9:V9}
"[13]": ${BFI_TAXRATE_13:V13}
# 付款申请推送配置(A103-3 付款申请保存)
payApply:
# 单据类型编码:ap_payapply_BT_S 采购付款申请、ap_payapply_oth_BT_S 其他付款申请
+5
View File
@@ -127,6 +127,11 @@ thirdParty:
readTimeoutSeconds: ${BFI_READ_TIMEOUT_SECONDS:120}
# 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS)
srcSys: ${BFI_SRC_SYS:WLTMS}
# 项目编码真实化开关:开启后推送金蝶的项目编码取 blade_project_apply.project_code 真实值
# (前提:金蝶项目档案已存在且编码与 project_code 一致,否则该单推送失败回写单据可重推;未关联项目兜底 999)
projectRealCodeEnabled: ${BFI_PROJECT_REAL_CODE_ENABLED:false}
# 合同引用开关:开启后财务应付/应收送上游合同号(仅 kingdee_id 非空的已同步合同)、开票申请送合同三件套(contractdate 取申请日期)
contractRefEnabled: ${BFI_CONTRACT_REF_ENABLED:false}
# 金蝶黑名单查询(A801-5 客商提审拦截):禁止交易类(A)始终拦截;
# 风险警示类(B)默认放行告警,开启后一并拦截
blacklist:
+16
View File
@@ -64,6 +64,11 @@ thirdParty:
# 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS)
srcSys: ${BFI_SRC_SYS:WLTMS}
# 项目编码真实化开关:开启后推送金蝶的项目编码取 blade_project_apply.project_code 真实值
# (前提:金蝶项目档案已存在且编码与 project_code 一致,否则该单推送失败回写单据可重推;未关联项目兜底 999)
projectRealCodeEnabled: ${BFI_PROJECT_REAL_CODE_ENABLED:false}
# 合同引用开关:开启后财务应付/应收送上游合同号(仅 kingdee_id 非空的已同步合同)、开票申请送合同三件套(contractdate 取申请日期)
contractRefEnabled: ${BFI_CONTRACT_REF_ENABLED:false}
# 金蝶黑名单查询(A801-5 客商提审拦截):禁止交易类(A)始终拦截;
# 风险警示类(B)默认放行告警,开启后一并拦截
blacklist:
@@ -86,6 +91,17 @@ thirdParty:
loading: ${BFI_MATERIAL_LOADING:XDWL11002004}
# 仓储大类、手动添加仓储费用物料编码(仓储)
warehouse: ${BFI_MATERIAL_WAREHOUSE:XDWL11002001}
# 金蝶税率编码映射(A102-2 财务应付单分录 taxrateid_number):税率数值 -> 税率.编码(如 9 -> V9),
# 键为税率数值文本(含小数税率如 "[6.5]"),未维护该税率时推送拦截报错;
# 分录无税率(计费方案未配置税率组/残差组/手动结算合计费用行)时税率与税率编码均不送
taxrate:
"[0]": ${BFI_TAXRATE_0:V0}
"[1]": ${BFI_TAXRATE_1:V1}
"[3]": ${BFI_TAXRATE_3:V3}
"[6]": ${BFI_TAXRATE_6:V6}
"[9]": ${BFI_TAXRATE_9:V9}
"[13]": ${BFI_TAXRATE_13:V13}
# 付款申请推送配置(A103-3 付款申请保存)
payApply:
# 单据类型编码:ap_payapply_BT_S 采购付款申请、ap_payapply_oth_BT_S 其他付款申请
@@ -0,0 +1,15 @@
-- 收票登记新增所属合同编号:页面去掉手选结算组织,改为选择承运商合同带出承办部门
-- 口径:仅落 contract_no,合同名称/承办部门按编号实时带出;
-- 结算组织 = 合同承办部门(dept_id/dept_name 取合同 organization_id/organization_name),
-- 金蝶结算组织编码沿承办部门对应平台公司派生(KingdeeApBillSyncSupport#bfiOrgNumber)
-- 幂等可重复执行:列已存在时重复执行报 Duplicate column 属预期,跳过即可
ALTER TABLE `blade_invoice_receipt`
ADD COLUMN `contract_no` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所属合同编号(承运商合同)' AFTER `payee_name`;
-- 存量已审批未推送数据无所属合同,推送前需编辑补录(推送预检按 contract_no 拦截)
SELECT COUNT(*) AS approved_receipts_without_contract
FROM `blade_invoice_receipt`
WHERE `is_deleted` = 0
AND `approval_status` = 'approved'
AND (`contract_no` IS NULL OR `contract_no` = '');
@@ -0,0 +1,21 @@
-- 付款记录表:金蝶付款处理单内码(cas_paybill.id)落库
-- 用途:财务应付单(A102-2)preentry 预付信息分录 y_billid 取数;A103-4 回写付款记录时同步写入
-- 幂等可重复执行:逐列查 information_schema,已存在则跳过;存量数据从 voucher_json 回填(仅填空值)
-- ① 列 kingdee_bill_id
SET @ddl = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_payment_application_record' AND COLUMN_NAME = 'kingdee_bill_id') = 0,
'ALTER TABLE `blade_payment_application_record` ADD COLUMN `kingdee_bill_id` varchar(64) DEFAULT NULL COMMENT ''金蝶付款处理单id(cas_paybill.id,A103-4回写,应付单preentry预付抵扣取数用)'' AFTER `kingdee_bill_no`',
'SELECT 1'));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ② 存量回填:从 A103-4 留档报文 voucher_json 提取付款处理单 id(仅填空值,天然可重复执行)
UPDATE `blade_payment_application_record`
SET `kingdee_bill_id` = JSON_UNQUOTE(JSON_EXTRACT(`voucher_json`, '$.id'))
WHERE `kingdee_bill_id` IS NULL
AND `voucher_json` IS NOT NULL
AND JSON_VALID(`voucher_json`)
AND JSON_EXTRACT(`voucher_json`, '$.id') IS NOT NULL;