!11 收款结算单列表 + 金蝶+合同文档整合+ 新增仓储大类字典

Merge pull request !11 from mindless/feature/20261103-bfi-warehouse-material
This commit is contained in:
2026-11-04 13:17:10 +00:00
committed by Gitee
136 changed files with 3702 additions and 963 deletions
@@ -58,6 +58,14 @@ public class InvoiceReceiptSaveRequest implements Serializable {
private String departmentEmails;
private String attachmentsJson;
private String remark;
/**
* 结算组织(页面手选部门,先票后款链路收票登记不再必选关联结算单)
*/
private Long deptId;
private String deptName;
/**
* 关联结算单分摊(兼容历史入参:先票后款链路不再传,关联关系由付款结算单侧写入)
*/
private List<SettlementRow> settlements;
/**
@@ -8,6 +8,7 @@
*/
package org.springblade.transport.pojo.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -39,4 +40,10 @@ public class FormalSettlementInvoice extends TenantEntity {
private BigDecimal availableInvoiceAmount;
private BigDecimal matchedAmount;
private String attachmentJson;
@TableField(exist = false)
@Schema(description = "收票单金蝶推送状态(unsynced未推送/pushed已保存收票单/assigned已指定应付/failed推送失败),详情回显用")
private String kingdeePushStatus;
@TableField(exist = false)
@Schema(description = "收票单金蝶推送失败原因(详情失败行悬浮展示用)")
private String kingdeePushMsg;
}
@@ -112,6 +112,10 @@ public class InvoiceReceipt extends TenantEntity {
* 收款方
*/
private String payeeName;
/**
* 往来户金蝶供应商代码(发票销售方,保存时经客商档案/A901-1 回填,推送 A303-2 前强校验)
*/
private String supplierCode;
/**
* 开票单位开户银行
*/
@@ -48,6 +48,10 @@ public class FormalSettlementVO extends FormalSettlement implements KingdeeUnloc
@TableField(exist = false) private Integer attachmentCount;
@TableField(exist = false) private String attachmentSyncStatus;
@TableField(exist = false) private String attachmentSyncStatusName;
@TableField(exist = false) @Schema(description = "已指定应付聚合态(assigned全部已指定/failed存在指定失败/unspecified存在未指定),空展示-")
private String kingdeeAssignStatus;
@TableField(exist = false) @Schema(description = "指定失败原因(存在指定失败时聚合各票原因,悬浮展示)")
private String kingdeeAssignFailReason;
@TableField(exist = false) @Schema(description = "金蝶锁状态:locked-锁定中/failed-锁定失败;unlocked为终态不返回,null=无锁或已解锁")
private String kingdeeLockStatus;
@TableField(exist = false) @Schema(description = "金蝶锁失败原因(仅锁定失败时有值)")
@@ -50,6 +50,10 @@ public class PaymentApplicationVO extends PaymentApplication implements KingdeeU
@TableField(exist = false) private String kingdeeLockStatus;
@TableField(exist = false) private String kingdeeLockFailReason;
@TableField(exist = false) private Integer kingdeeTryCount;
/** 已指定应付聚合态(assigned全部已指定/failed存在指定失败/unspecified存在未指定),空展示- */
@TableField(exist = false) private String kingdeeAssignStatus;
/** 指定失败原因(存在指定失败时聚合各票原因,悬浮展示) */
@TableField(exist = false) private String kingdeeAssignFailReason;
/** 金蝶解锁终态标识(unlocked 锁记录回填):解锁时间非空即代表该单曾被金蝶解锁/删单,供页面展示明显标识 */
@TableField(exist = false) private LocalDateTime kingdeeUnlockTime;
/** 金蝶侧解锁操作人(回调报文 operator) */
@@ -235,6 +235,11 @@ public class BfiProperties {
* 装卸大类、手动添加非运费杂费物料编码(装卸)
*/
private String loading = "XDWL11002004";
/**
* 仓储大类、手动添加仓储费用物料编码(仓储)
*/
private String warehouse = "XDWL11002001";
}
/**
@@ -254,7 +259,7 @@ public class BfiProperties {
private long lockLeaseSeconds = 1800;
/**
* A103-4 付款处理查询业务日期窗口天数,默认 60
* A103-4 付款处理查询业务日期窗口天数(接口必填参数,按 awrp_sourcebillno 精确过滤单据),默认 60
*/
private int queryWindowDays = 60;
}
@@ -269,6 +274,13 @@ public class BfiProperties {
* 回写任务开关,默认关闭(联调稳定后开启)
*/
private boolean syncEnabled = false;
/**
* A302-6 指定应付自动补偿开关:pushed 且已认领的收票单是否由本回写任务自动重发指定应付,
* 默认关闭(安全兜底),不自动推送指定应付,pushed 单据由人工入口
* (发票登记推送 / 认领发票重推按钮)触发指定;置 true 开启自动补偿
*/
private boolean assignCompensationEnabled = false;
}
/**
@@ -192,6 +192,9 @@ public final class KingdeeBfiConstant {
/** A303-2 业务类型(PUR:采购 / FEE:费用,TMS 服务费进项票暂按采购)。待确认:取值需金蝶侧最终确认。 */
public static final String BFI_RECEIVED_BIZ_TYPE = "PUR";
/** A303-2 结算币别:收票登记不挂合同(先票后款),数电票固定人民币。 */
public static final String BFI_RECEIVED_CURRENCY_NUMBER = "CNY";
/** A303-2 必填:外部单据类型(收票单来源标识)。待确认:取值需金蝶侧确认,确认后如有调整直接改此常量。 */
public static final String BFI_RECEIVED_INVOICE_SRC_TYPE = "TMS收票登记";
@@ -201,20 +201,7 @@ public class FormalSettlementController extends BladeController {
@Operation(summary = "审批通过")
public R approve(@RequestBody FormalSettlementStatusRequest request) {
formalSettlementService.approve(request);
return approveResponse();
}
/** 审批接口统一响应:审批已生效,自动推送结果(成功单据号/失败原因)一并返回;失败用 data=false 供前端区分提示样式 */
private R approveResponse() {
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (syncResult != null) {
return R.success("审批通过,已同步金蝶:" + syncResult);
}
return R.success("审批通过");
return KingdeeApprovalSyncListener.billSyncedResponse();
}
@PostMapping("/return")
@@ -232,6 +219,21 @@ public class FormalSettlementController extends BladeController {
@Operation(summary = "推送金蝶(应付单/应收单)")
public R<String> syncKingdee(@RequestParam Long id) { return R.data(formalSettlementService.syncKingdee(id)); }
@PostMapping("/invoice-assign-kingdee")
@ApiOperationSupport(order = 14)
@Operation(summary = "发票登记确认推送金蝶(先票后款:认领收票单逐单 A302-6 指定应付)")
public R<String> invoiceAssignKingdee(@RequestParam Long id) {
return R.data(formalSettlementService.pushInvoiceAssignKingdee(id));
}
@PostMapping("/repush-claimed-invoice")
@ApiOperationSupport(order = 16)
@Operation(summary = "重推认领收票单(查看页入口:收票单A303-2+指定应付A302-6 一步式,failed/pushed 通吃,可带匹配金额)")
public R<String> repushClaimedInvoice(@RequestParam Long id, @RequestParam String invoiceNo,
@RequestParam(required = false) BigDecimal matchedAmount) {
return R.data(formalSettlementService.repushClaimedInvoice(id, invoiceNo, matchedAmount));
}
@PostMapping("/sync-kingdee-ar-result")
@ApiOperationSupport(order = 15)
@Operation(summary = "手动勾稽金蝶应收单(A302-4 回写纠偏)")
@@ -131,20 +131,7 @@ public class InvoiceApplicationController extends BladeController {
@Operation(summary = "审批通过开票申请")
public R approve(@RequestBody InvoiceApplicationStatusRequest request) {
invoiceApplicationService.approve(request);
return approveResponse();
}
/** 审批接口统一响应:审批已生效,自动推送结果(成功单据号/失败原因)一并返回;失败用 data=false 供前端区分提示样式 */
private R approveResponse() {
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (syncResult != null) {
return R.success("审批通过,已同步金蝶:" + syncResult);
}
return R.success("审批通过");
return KingdeeApprovalSyncListener.billSyncedResponse();
}
@PostMapping("/return")
@@ -36,10 +36,12 @@ 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.thirdparty.bfi.pojo.vo.BfiInvoicePoolItemVO;
import org.springblade.transport.listener.KingdeeApprovalSyncListener;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
import org.springblade.transport.pojo.vo.InvoiceReceiptVO;
import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO;
import org.springblade.transport.service.IInvoiceReceiptService;
@@ -147,20 +149,7 @@ public class InvoiceReceiptController extends BladeController {
@Operation(summary = "审批通过收票登记")
public R approve(@RequestBody InvoiceReceiptStatusRequest request) {
invoiceReceiptService.approve(request);
return approveResponse();
}
/** 审批接口统一响应:审批已生效,自动推送结果(成功单据号/失败原因)一并返回;失败用 data=false 供前端区分提示样式 */
private R approveResponse() {
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (syncResult != null) {
return R.success("审批通过,已同步金蝶:" + syncResult);
}
return R.success("审批通过");
return KingdeeApprovalSyncListener.summarySyncedResponse();
}
@PostMapping("/return")
@@ -181,7 +170,7 @@ public class InvoiceReceiptController extends BladeController {
@PostMapping("/push-kingdee")
@ApiOperationSupport(order = 15)
@Operation(summary = "批量推送金蝶收票单(A303-2 收票单保存 + A302-6 收票指定应付)")
@Operation(summary = "批量推送金蝶收票单(A303-2 收票单保存,先票后款:指定应付由付款结算单侧执行)")
public R<String> pushKingdee(@RequestBody List<Long> ids) {
return R.data(invoiceReceiptService.pushKingdeeReceivedInvoice(ids));
}
@@ -192,4 +181,25 @@ public class InvoiceReceiptController extends BladeController {
public R<String> syncKingdeeResult() {
return R.data(invoiceReceiptService.syncKingdeeReceivedInvoiceResult());
}
@GetMapping("/invoice-detail-query")
@ApiOperationSupport(order = 17)
@Operation(summary = "发票明细查询(先票后款:收票登记明细区块按票号带出发票明细行)")
public R<List<BfiInvoicePoolItemVO>> invoiceDetailQuery(@RequestParam String invoiceNo) {
return R.data(invoiceReceiptService.invoiceDetailQuery(invoiceNo));
}
@GetMapping("/claim-candidates")
@ApiOperationSupport(order = 18)
@Operation(summary = "结算单认领收票单候选分页(先票后款:审批通过且未被有效应付结算单认领)")
public R<IPage<InvoiceReceipt>> claimCandidates(@RequestParam(required = false) String keyword, Query pageQuery) {
return R.data(invoiceReceiptService.claimCandidates(keyword, Condition.getPage(pageQuery)));
}
@GetMapping("/claimed-list")
@ApiOperationSupport(order = 19)
@Operation(summary = "结算单已认领收票登记列表(先票后款:发票登记对话框回显已认领票)")
public R<List<InvoiceReceipt>> claimedList(@RequestParam Long formalSettlementId) {
return R.data(invoiceReceiptService.claimedReceipts(formalSettlementId));
}
}
@@ -106,29 +106,21 @@ public class PaymentApplicationController extends BladeController {
return approveResponse(request.getId());
}
/** 审批接口统一响应:审批已生效,付款特批发起/自动同步结果(成功单据号/失败原因)一并返回;失败用 data=false 供前端区分提示样式 */
/** 审批接口统一响应:审批已生效,付款特批发起结果优先返回;特批完成/未走特批时金蝶同步结果走统一响应(成功单据号/失败原因) */
private R approveResponse(Long id) {
String hcsFailure = HcsImmediatePushSupport.pollSyncFailure();
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
if (hcsFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但发起付款特批/推送合规失败:" + hcsFailure + ",可稍后重推合规");
}
PaymentApplication payment = paymentApplicationService.getById(id);
String specStatus = payment == null ? null : payment.getHcsSpecStatus();
if (HcsSyncConstant.SPEC_STATUS_NEED.equals(specStatus)) {
if (hcsImmediatePushSupport.isSpecPayEnabled() && HcsSyncConstant.SPEC_STATUS_NEED.equals(specStatus)) {
return R.success("审批通过,付款特批待发起,可在详情页重推合规");
}
if (Arrays.asList(HcsSyncConstant.SPEC_STATUS_PUSHING, HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(specStatus)) {
return R.success("审批通过,已发起付款特批,特批通过后将自动推送履约数据并同步金蝶");
}
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (syncResult != null) {
return R.success("审批通过,已同步金蝶:" + syncResult);
}
return R.success("审批通过");
return KingdeeApprovalSyncListener.billSyncedResponse();
}
@PostMapping("/return")
@ApiOperationSupport(order = 8)
@@ -36,20 +36,9 @@ public class SettlementAdjustmentController extends BladeController {
@PostMapping("/remove") public R remove(@RequestParam Long id) { service.removeDraft(id); return R.success("删除成功"); }
@PostMapping("/submit") public R submit(@RequestBody SettlementAdjustmentStatusRequest request) { service.submit(request); return R.success("提交成功"); }
@PostMapping("/approve") public R approve(@RequestBody SettlementAdjustmentStatusRequest request) {
// 未推送场景自动推送失败原因以警告文案返回(data=false 供前端区分提示样式);hint 优先于同步成功提示
String hint = service.approve(request);
// 未推送场景自动推送失败原因以警告文案返回(data=false 供前端区分提示样式)
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
if (Func.isNotBlank(syncFailure)) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (Func.isNotBlank(hint)) {
return R.data(Boolean.TRUE, "审批通过;" + hint);
}
if (Func.isNotBlank(syncResult)) {
return R.success("审批通过,已同步金蝶:" + syncResult);
}
return R.success("审批通过");
return KingdeeApprovalSyncListener.billSyncedResponse(Func.isBlank(hint) ? null : hint);
}
@PostMapping("/return") public R returnBill(@RequestBody SettlementAdjustmentStatusRequest request) { service.returnBill(request); return R.success("已驳回"); }
@PostMapping("/repush") public R<String> repush(@RequestParam Long id) { return R.data(service.repush(id)); }
@@ -21,8 +21,10 @@ package org.springblade.transport.listener;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tool.api.R;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.transport.event.KingdeeBillApprovedEvent;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.service.IFormalSettlementService;
@@ -64,6 +66,7 @@ public class KingdeeApprovalSyncListener {
private final IInvoiceApplicationService invoiceApplicationService;
private final IInvoiceReceiptService invoiceReceiptService;
private final BfiProperties bfiProperties;
private final HcsProperties hcsProperties;
/** 当前审批请求线程的自动推送失败原因,供 Controller 取走提示前端(见类注释线程说明) */
private static final ThreadLocal<String> SYNC_FAILURE = new ThreadLocal<>();
@@ -98,6 +101,47 @@ public class KingdeeApprovalSyncListener {
}
}
/**
* 审批接口统一金蝶同步响应(单据号域:正式结算单/开票申请/付款申请/结算调整):
* 同步失败原因以 data=false 返回供前端区分提示样式,成功提示"单据号[xxx]已同步金蝶",未触发推送返回"审批通过"
*/
public static R billSyncedResponse() {
return billSyncedResponse(null);
}
/**
* 同 {@link #billSyncedResponse()},pendingHint 非空时优先于同步成功提示返回(结算调整 hint 场景)
*
* @param pendingHint 审批附带提示(如"存在未勾稽明细"),无同步失败时以 data=true 警告返回
*/
public static R billSyncedResponse(String pendingHint) {
String syncResult = pollSyncResult();
return response(syncResult == null ? null : "审批通过,单据号[" + syncResult + "]已同步金蝶", pendingHint);
}
/**
* 审批接口统一金蝶同步响应(收票登记域):A303-2 推送结果为笔数汇总而非单据号,成功提示原样返回汇总文案
*/
public static R summarySyncedResponse() {
String syncResult = pollSyncResult();
return response(syncResult == null ? null : "审批通过,已同步金蝶:" + syncResult, null);
}
/** 同步响应骨架:同步失败原因优先(data=false),其次审批附带提示(data=true),再次成功提示,均无返回"审批通过" */
private static R response(String syncedTip, String pendingHint) {
String syncFailure = pollSyncFailure();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (pendingHint != null) {
return R.data(Boolean.TRUE, "审批通过;" + pendingHint);
}
if (syncedTip != null) {
return R.success(syncedTip);
}
return R.success("审批通过");
}
/**
* 事务提交后同步推送。fallbackExecution=true 兜底:approve 未开启事务时事件不再被默认丢弃。
*
@@ -141,18 +185,19 @@ public class KingdeeApprovalSyncListener {
return;
}
// 付款特批未完成(高风险/黑名单/额度超额)时金蝶必然拒推:跳过而非置失败,
// 特批通过后由 HcsPaymentSyncSupport 重新发布本事件自动补推
// 特批通过后由 HcsPaymentSyncSupport 重新发布本事件自动补推;
// 特批不走合同系统(spec-pay.enabled=false)时不跳过
PaymentApplication payment = paymentApplicationService.getById(id);
if (payment != null && payment.getHcsSpecStatus() != null
if (hcsProperties.getSpecPay().isEnabled() && payment != null && payment.getHcsSpecStatus() != null
&& List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PUSHING,
HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(payment.getHcsSpecStatus())) {
log.info("付款申请[{}]审批通过自动同步金蝶跳过(付款特批未完成):hcsSpecStatus={}", id, payment.getHcsSpecStatus());
return;
}
log.info("付款申请[{}]审批通过自动同步金蝶开始", id);
String kingdeeBillId = paymentApplicationService.syncKingdee(id);
recordResult(kingdeeBillId);
log.info("付款申请[{}]审批通过自动同步金蝶完成:kingdeeBillId={}", id, kingdeeBillId);
String billNo = paymentApplicationService.syncKingdee(id);
recordResult(billNo);
log.info("付款申请[{}]审批通过自动同步金蝶完成:billNo={}", id, billNo);
}
private void pushInvoiceApply(Long id) {
@@ -18,6 +18,7 @@ import org.springblade.transport.pojo.vo.PreSettlementVO;
import org.springblade.transport.pojo.entity.PreSettlement;
import org.springblade.transport.pojo.dto.PreSettlementDetailAdjustRequest;
import org.springblade.transport.pojo.entity.FormalSettlementDetailFee;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest;
@@ -62,6 +63,28 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
*/
String doSyncKingdee(Long id);
/**
* 发票登记确认推送金蝶(先票后款路径B:结算单审批通过后"发票登记"关联收票单 → 指定应付)。
* 前置:付款结算单 + 审批通过 + 财务应付单已同步;对本单认领且收票单已推(pushed)的
* 逐单调 A302-6 指定应付,单笔失败整体报因(已指定的幂等跳过,可重试)
*
* @param settlementId 正式结算单id
* @return 推送结果描述
*/
String pushInvoiceAssignKingdee(Long settlementId);
/**
* 重推本单认领的收票登记(查看页「重新推送」入口,failed/pushed 通吃,assigned 拦截):
* 前置付款结算单 + 审批通过 + 财务应付单已同步;先复用收票推送状态机重推金蝶收票单(A303-2,
* A801-3 幂等预检防重复保存),再组装应付单防重键逐单 A302-6 指定应付(幂等)
*
* @param id 正式结算单id
* @param invoiceNo 认领发票号
* @param matchedAmount 匹配结算单金额(可空:空不修改;非空校验 0~发票金额 后更新发票行并重建认领分摊)
* @return 重推结果描述
*/
String repushClaimedInvoice(Long id, String invoiceNo, BigDecimal matchedAmount);
/**
* 独立事务落"生成失败"状态并记失败原因(在推送事务回滚、行锁释放后执行,与付款/收票口径统一);
* 仅更新审批通过且未同步的单据,未审批/已同步/已删除单据自动跳过,保持原状态
@@ -28,6 +28,7 @@ package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import jakarta.servlet.http.HttpServletResponse;
import org.springblade.core.mp.base.BaseService;
import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolItemVO;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
@@ -105,10 +106,10 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
void voidBill(InvoiceReceiptStatusRequest request);
/**
* 批量推送金蝶收票单(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付)。
* <p>逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、恰好分摊一张应付结算单)→
* A102-4 预检财务应付单已生成 → A801-3 幂等预检(命中跳过保存续推指定)→ A303-2 保存 →
* 立即 A302-6 指定应付;成功置 assigned 并锁单,失败置 failed 提示人工处理后重推。
* 批量推送金蝶收票单(A303-2 收票单批量保存,先票后款:推送即终态 pushed)。
* <p>逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、已选结算组织)→
* A801-3 幂等预检(命中跳过保存)→ A303-2 保存(分录=发票明细行)→ 终态 pushed;
* A302-6 指定应付由付款结算单侧在财务应付单(A102-2)生成后按认领关系执行
*
* @param ids 收票登记id列表
* @return 执行摘要(逐单失败原因)
@@ -117,7 +118,7 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
/**
* 自动推送路径批量推送金蝶收票单(审批通过事件 / 定时重推专用):
* 前置拦截(未绑定镜像/查验未通过/前置应付单未生成等)一并置 failed + 原因,纳入定时重推循环
* 前置拦截(未绑定镜像/查验未通过/未选结算组织等)一并置 failed + 原因,纳入定时重推循环
*
* @param ids 收票登记id列表
* @param autoSync 自动推送标记(前置拦截置 failed)
@@ -125,6 +126,54 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
*/
String pushKingdeeReceivedInvoice(List<Long> ids, boolean autoSync);
/**
* 发票明细查询(先票后款:收票登记明细区块按票号带出):读票池镜像 items_json,
* 镜像无明细按票号回查 A801-1 补写后重读;票未登记时直接取窄查返回行明细
*
* @param invoiceNo 发票号码
* @return 发票明细行
*/
List<BfiInvoicePoolItemVO> invoiceDetailQuery(String invoiceNo);
/**
* 结算单认领收票单候选(先票后款路径A/B 选票数据源):审批通过 + 未被有效应付结算单认领,
* 关键字匹配发票号/销方/购方,分页返回
*
* @param keyword 关键字(发票号/销方/购方),空不过滤
* @param page 分页参数
* @return 候选收票登记分页
*/
IPage<InvoiceReceipt> claimCandidates(String keyword, IPage<InvoiceReceipt> page);
/**
* 结算单已认领收票登记列表(先票后款:发票登记对话框打开时回显已认领票):
* 按认领关系查收票登记,与认领候选行结构一致(同一 InvoiceReceipt 实体)
*
* @param settlementId 正式结算单id
* @return 本单已认领的收票登记
*/
List<InvoiceReceipt> claimedReceipts(Long settlementId);
/**
* 结算单收票认领关系同步(先票后款:结算单保存/认领后由结算单侧调用):
* 按行发票号匹配审批通过收票登记,一票一结算单校验(已挂其他有效应付结算单拦截),
* 删旧建新重建本结算单认领关系(认领金额=发票金额全额)并刷新结算单收票状态
*
* @param settlementId 正式结算单id
* @param invoiceNos 认领发票号码集合(结算单发票信息行发票号)
*/
void syncSettlementReceiptRelations(Long settlementId, List<String> invoiceNos);
/**
* 结算单认领收票登记逐单 A302-6 指定应付(先票后款路径:A102-2 成功后调用):
* 收票单 pushed → 指定 → assigned;未推送/已指定跳过;单笔失败收集汇总返回,不阻断结算同步
*
* @param settlementId 正式结算单id
* @param apBillSrcCode 财务应付单防重键(A102-2 的 awrp_src_code)
* @return 失败明细(空串表示全部成功或无需指定)
*/
String assignClaimedReceipts(Long settlementId, String apBillSrcCode);
/**
* 重推金蝶推送失败(kingdeePushStatus=failed 且审批通过)的收票登记:逐单独立成败,失败不中断。
* 只扫 failed 不扫 unsynced——存量"审批通过但从未推送"的历史单不自动补推,仍走手动按钮
@@ -135,7 +184,7 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
/**
* 定时回写金蝶收票单结果(A801-3):金蝶侧删除/红冲/作废 → 回收为 failed 提示人工(不自动作废登记);
* pushed(保存成功后中断)→ 指定补偿;assigned 仅补登缺失的收票单号
* pushed 且已认领 → 指定补偿(重试引擎),未认领跳过;assigned 仅补登缺失的收票单号
*
* @return 执行摘要
*/
@@ -230,9 +230,72 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
IPage<FormalSettlementVO> voPage = result.convert(item -> this.toVO(item, writeOffAmounts));
fillAttachmentSyncStatus(voPage.getRecords());
fillKingdeeLockStatus(voPage.getRecords());
fillKingdeeAssignStatus(voPage.getRecords());
return voPage;
}
/**
* 填充已指定应付聚合态(仅付款结算单):按本页结算单一次批量查认领关系与收票登记,避免 N+1。
* 聚合规则:任一指定失败 → failed;全部已指定 → assigned;其余(有认领但未指定/未推送)→ unspecified;
* 无认领收票或全部认领票无效不填充,前端展示"-"
*
* @param rows 本页 VO 列表
*/
private void fillKingdeeAssignStatus(List<FormalSettlementVO> rows) {
// 已指定应付仅付款结算单(先票后款 A302-6)有,收款结算单不参与聚合,避免无意义查询与字段下发
List<FormalSettlementVO> payableRows = rows.stream()
.filter(row -> "payable".equals(row.getSettlementType()))
.toList();
if (Func.isEmpty(payableRows)) {
return;
}
List<InvoiceReceiptSettlement> relations = invoiceReceiptSettlementMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.in(InvoiceReceiptSettlement::getFormalSettlementId,
payableRows.stream().map(FormalSettlementVO::getId).filter(Objects::nonNull).toList()));
if (relations.isEmpty()) {
return;
}
List<Long> receiptIds = relations.stream()
.map(InvoiceReceiptSettlement::getInvoiceReceiptId).filter(Objects::nonNull).distinct().toList();
Map<Long, InvoiceReceipt> receiptById = invoiceReceiptMapper.selectByIds(receiptIds).stream()
.filter(receipt -> !TransportBusiConstant.VOIDED.equals(receipt.getApprovalStatus())
&& !Objects.equals(receipt.getIsDeleted(), 1))
.collect(Collectors.toMap(InvoiceReceipt::getId, receipt -> receipt, (left, right) -> left));
Map<Long, List<InvoiceReceipt>> receiptsBySettlement = new HashMap<>();
for (InvoiceReceiptSettlement relation : relations) {
InvoiceReceipt receipt = receiptById.get(relation.getInvoiceReceiptId());
if (receipt == null) {
continue;
}
receiptsBySettlement.computeIfAbsent(relation.getFormalSettlementId(), key -> new ArrayList<>())
.add(receipt);
}
for (FormalSettlementVO vo : payableRows) {
List<InvoiceReceipt> claimed = receiptsBySettlement.get(vo.getId());
if (Func.isEmpty(claimed)) {
continue;
}
List<String> statuses = claimed.stream()
.map(receipt -> Func.toStrWithEmpty(receipt.getKingdeePushStatus(),
KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED))
.toList();
String status = statuses.contains(KingdeeBfiConstant.RECEIVED_PUSH_FAILED) ? "failed"
: statuses.stream().allMatch(KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED::equals)
? "assigned" : "unspecified";
vo.setKingdeeAssignStatus(status);
// 指定失败行聚合各票失败原因,供悬浮展示
if ("failed".equals(status)) {
vo.setKingdeeAssignFailReason(claimed.stream()
.filter(receipt -> KingdeeBfiConstant.RECEIVED_PUSH_FAILED.equals(
Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED)))
.map(receipt -> "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId()))
+ ":" + Func.toStrWithEmpty(receipt.getKingdeePushMsg(), "推送失败"))
.collect(Collectors.joining(";")));
}
}
}
/**
* 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回):
* 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废,
@@ -454,13 +517,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.or()
.eq(InvoiceReceiptSettlement::getFormalSettlementNo, settlement.getFormalSettlementNo()))
.orderByAsc(InvoiceReceiptSettlement::getCreateTime));
Map<Long, InvoiceReceipt> receiptMap = Map.of();
if (!receiptRelations.isEmpty()) {
List<Long> receiptIds = receiptRelations.stream()
.map(InvoiceReceiptSettlement::getInvoiceReceiptId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, InvoiceReceipt> receiptMap = receiptIds.isEmpty() ? Map.of()
receiptMap = receiptIds.isEmpty() ? Map.of()
: invoiceReceiptMapper.selectByIds(receiptIds).stream()
.filter(receipt -> !TransportBusiConstant.VOIDED.equals(receipt.getApprovalStatus())
&& !Objects.equals(receipt.getIsDeleted(), 1))
@@ -483,6 +547,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
List<FormalSettlementInvoice> invoices = new ArrayList<>(invoiceMap.values());
// 发票行回显收票单金蝶推送状态(是否已指定应付展示用):认领收票按票号匹配本单已认领收票登记
Map<String, InvoiceReceipt> receiptByInvoiceNo = receiptMap.values().stream()
.filter(receipt -> Func.isNotBlank(receipt.getInvoiceNo()))
.collect(Collectors.toMap(InvoiceReceipt::getInvoiceNo, Function.identity(), (left, right) -> left));
for (FormalSettlementInvoice invoice : invoices) {
InvoiceReceipt receipt = receiptByInvoiceNo.get(invoice.getInvoiceNo());
if (receipt != null) {
invoice.setKingdeePushStatus(receipt.getKingdeePushStatus());
invoice.setKingdeePushMsg(receipt.getKingdeePushMsg());
}
}
for (int index = 0; index < invoices.size(); index++) {
invoices.get(index).setLineNo(index + 1);
}
@@ -1008,6 +1083,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
String billNo;
String billPkId;
String changeDesc;
// 应付结算:财务应付单防重键,先票后款链路 A102-2 成功后据此对本单认领收票单逐单 A302-6 指定应付
String apBillSrcCode = null;
if ("receivable".equals(settlement.getSettlementType())) {
// 应收结算:财务应收单防重键(awrp_src_code):正式结算单号_结算明细单据号下划线拼接
String arBillSrcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details);
@@ -1020,7 +1097,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
} else {
// 应付结算:财务应付单防重键,付款申请「同步金蝶」(A103-1 应付下推)推送前按该键经 A102-4 预检本单应付单已生成
// 付款申请 A103-1 下推前的前置
String apBillSrcCode = kingdeeApBillSyncSupport.apBillSrcCode(settlement, details);
apBillSrcCode = kingdeeApBillSyncSupport.apBillSrcCode(settlement, details);
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups = kingdeeApBillSyncSupport.resolveEntryGroups(settlement, details, context);
KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeApBillSyncSupport.pushApBill(
apBillSrcCode, settlement, entryGroups, settlement.getFormalSettlementNo(), context);
@@ -1042,11 +1119,143 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.eq(SettlementAdjustment::getApprovalStatus, TransportBusiConstant.APPROVED)
.set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED)
.set(SettlementAdjustment::getKingdeeBillNo, billNo));
// 先票后款:应付单推送成功后,对本单认领且收票单已推金蝶(pushed)的登记逐单 A302-6 指定应付;
// 单笔失败不阻断结算同步,由 A801-3 回写"指定补偿"按认领关系自动重试
if (apBillSrcCode != null) {
String assignFailures = invoiceReceiptService.assignClaimedReceipts(settlement.getId(), apBillSrcCode);
if (Func.isNotBlank(assignFailures)) {
log.warn("正式结算单[{}]收票单指定应付存在失败(由 A801-3 指定补偿重试):{}",
settlement.getFormalSettlementNo(), assignFailures);
}
}
log.info("正式结算单[{}]同步金蝶完成:settlementType={},billNo={},billPkId={}",
settlement.getFormalSettlementNo(), settlement.getSettlementType(), billNo, billPkId);
return billNo;
}
/**
* 发票登记确认推送金蝶(先票后款路径B:结算单审批通过后列表"发票登记"关联收票单 → 指定应付)。
* 前置:付款结算单 + 审批通过 + 财务应付单已同步(kingdeeSyncStatus=synced);
* 对本单认领且收票单已推金蝶(pushed)的登记逐单 A302-6 指定应付,单笔失败整体报因(已指定的幂等跳过,可重试)
*
* @param settlementId 正式结算单id
* @return 推送结果描述
*/
@Override
public String pushInvoiceAssignKingdee(Long settlementId) {
FormalSettlement settlement = lockedExisting(settlementId);
if (!"payable".equals(settlement.getSettlementType())) {
throw new ServiceException("仅付款结算单允许发票登记推送金蝶");
}
if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) {
throw new ServiceException("仅审批通过的付款结算单允许发票登记推送金蝶");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(settlement.getKingdeeSyncStatus())) {
throw new ServiceException("请先同步金蝶财务应付单后再执行发票登记推送");
}
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, settlementId)
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
if (Func.isEmpty(details)) {
throw new ServiceException("推送失败:正式结算单无结算明细,无法组装财务应付单防重键");
}
String apBillSrcCode = kingdeeApBillSyncSupport.apBillSrcCode(settlement, details);
String assignFailures = invoiceReceiptService.assignClaimedReceipts(settlement.getId(), apBillSrcCode);
if (Func.isNotBlank(assignFailures)) {
log.warn("正式结算单[{}]发票登记推送金蝶存在失败:{}", settlement.getFormalSettlementNo(), assignFailures);
throw new ServiceException("发票登记推送金蝶完成,存在失败(已指定的幂等跳过,处理后可重试):" + assignFailures);
}
log.info("正式结算单[{}]发票登记推送金蝶完成:认领收票单已全部指定应付,应付单防重键={}",
settlement.getFormalSettlementNo(), apBillSrcCode);
return "结算单[" + settlement.getFormalSettlementNo() + "]发票登记推送金蝶成功:认领收票单已指定应付";
}
@Override
public String repushClaimedInvoice(Long id, String invoiceNo, BigDecimal matchedAmount) {
FormalSettlement settlement = lockedExisting(id);
if (!"payable".equals(settlement.getSettlementType())) {
throw new ServiceException("仅付款结算单允许重推认领收票单");
}
if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) {
throw new ServiceException("仅审批通过的付款结算单允许重推认领收票单");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(settlement.getKingdeeSyncStatus())) {
throw new ServiceException("请先同步金蝶财务应付单后再重推认领收票单");
}
String trimmedInvoiceNo = requiredText(invoiceNo, "发票号");
InvoiceReceipt receipt = invoiceReceiptMapper.selectOne(Wrappers.<InvoiceReceipt>lambdaQuery()
.eq(InvoiceReceipt::getInvoiceNo, trimmedInvoiceNo).last("LIMIT 1"));
if (receipt == null || Objects.equals(receipt.getIsDeleted(), 1)) {
throw new ServiceException("收票登记不存在:发票" + trimmedInvoiceNo);
}
// 认领关系校验:该票必须认领在本单名下,防止跨结算单误推
Long relationCount = invoiceReceiptSettlementMapper.selectCount(Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getFormalSettlementId, id)
.eq(InvoiceReceiptSettlement::getInvoiceReceiptId, receipt.getId()));
if (relationCount == null || relationCount == 0) {
throw new ServiceException("发票" + trimmedInvoiceNo + "未认领在本结算单名下,不允许重推");
}
String pushStatus = Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED);
if (KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(pushStatus)) {
throw new ServiceException("发票" + trimmedInvoiceNo + "已指定应付,无需重复推送");
}
// 匹配结算单金额随重推一并生效:更新发票行并重建本单认领分摊(收票覆盖度随之刷新)
if (matchedAmount != null) {
FormalSettlementInvoice settlementInvoice = invoiceMapper.selectOne(
Wrappers.<FormalSettlementInvoice>lambdaQuery()
.eq(FormalSettlementInvoice::getFormalSettlementId, id)
.eq(FormalSettlementInvoice::getInvoiceNo, trimmedInvoiceNo)
.last("LIMIT 1"));
if (settlementInvoice == null) {
throw new ServiceException("发票" + trimmedInvoiceNo + "不在本结算单发票信息中,无法修改匹配金额");
}
if (matchedAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new ServiceException("发票" + trimmedInvoiceNo + "匹配结算单金额不能为负数");
}
if (matchedAmount.compareTo(money(settlementInvoice.getInvoiceAmount())) > 0) {
throw new ServiceException("发票" + trimmedInvoiceNo + "匹配结算单金额不能超过发票金额"
+ money(settlementInvoice.getInvoiceAmount()));
}
invoiceMapper.update(null, Wrappers.<FormalSettlementInvoice>lambdaUpdate()
.eq(FormalSettlementInvoice::getId, settlementInvoice.getId())
.set(FormalSettlementInvoice::getMatchedAmount, matchedAmount));
// 关系重建语义为删全量重建,须传本单全部发票号,只传单票会清掉其他认领关系
List<String> allInvoiceNos = invoiceMapper.selectList(Wrappers.<FormalSettlementInvoice>lambdaQuery()
.eq(FormalSettlementInvoice::getFormalSettlementId, id)).stream()
.map(FormalSettlementInvoice::getInvoiceNo).filter(Func::isNotBlank).distinct().toList();
invoiceReceiptService.syncSettlementReceiptRelations(id, allInvoiceNos);
}
// 第一步:复用收票推送状态机重推金蝶收票单(A303-2,A801-3 幂等预检防重复保存,单笔事务独立回滚)
String pushResult = invoiceReceiptService.pushKingdeeReceivedInvoice(List.of(receipt.getId()));
// 第二步:收票单就绪后按应付单防重键 A302-6 指定应付(幂等,已指定的跳过)
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, id)
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
if (Func.isEmpty(details)) {
throw new ServiceException("推送失败:正式结算单无结算明细,无法组装财务应付单防重键");
}
String apBillSrcCode = kingdeeApBillSyncSupport.apBillSrcCode(settlement, details);
String assignFailures = invoiceReceiptService.assignClaimedReceipts(id, apBillSrcCode);
// 复读最新状态组装结果:assigned 即成功;收票单仍未推送成功按失败报因;pushed 未指定报因可重试
InvoiceReceipt latest = invoiceReceiptMapper.selectById(receipt.getId());
String latestStatus = Func.toStrWithEmpty(latest.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED);
if (KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(latestStatus)) {
log.info("正式结算单[{}]认领发票[{}]重推完成:已指定应付", settlement.getFormalSettlementNo(), trimmedInvoiceNo);
return "发票" + trimmedInvoiceNo + "重推成功:已指定应付";
}
if (!KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(latestStatus)) {
throw new ServiceException("发票" + trimmedInvoiceNo + "重推收票单失败:" + pushResult);
}
if (Func.isNotBlank(assignFailures)) {
throw new ServiceException("发票" + trimmedInvoiceNo + "收票单已推送金蝶,指定应付失败:" + assignFailures
+ "(已指定的自动跳过,可重试)");
}
log.info("正式结算单[{}]认领发票[{}]重推完成:收票单已推送金蝶并指定应付", settlement.getFormalSettlementNo(), trimmedInvoiceNo);
return "发票" + trimmedInvoiceNo + "重推成功:已指定应付";
}
@Override
public String syncKingdeeArResult() {
return kingdeeSyncLockSupport.runSingleFlight(AR_RESULT_SYNC_LOCK_PREFIX, bfiProperties.getArResult().getLockLeaseSeconds(),
@@ -1291,17 +1500,23 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
FormalSettlement settlement = existing(request.getFormalSettlementId());
List<FormalSettlementSaveRequest.Invoice> invoices = request.getInvoices();
Set<String> invoiceNumbers = new LinkedHashSet<>();
BigDecimal matchedTotal = BigDecimal.ZERO;
for (FormalSettlementSaveRequest.Invoice item : invoices) {
String invoiceNo = item == null ? "" : requiredText(item.getInvoiceNo(), "发票号");
if (!invoiceNumbers.add(invoiceNo)) throw new ServiceException("发票号" + invoiceNo + "重复");
matchedTotal = matchedTotal.add(money(item.getMatchedAmount()));
// 匹配结算单金额支持手动修改:后端兜底校验 0 ≤ 匹配金额 ≤ 发票金额(匹配合计不设上限,两个口径)
BigDecimal matched = money(item.getMatchedAmount());
BigDecimal invoiceAmount = money(item.getInvoiceAmount());
if (matched.compareTo(BigDecimal.ZERO) < 0) {
throw new ServiceException("发票" + invoiceNo + "匹配结算单金额不能为负数");
}
if (matched.compareTo(invoiceAmount) > 0) {
throw new ServiceException("发票" + invoiceNo + "匹配结算单金额不能超过发票金额" + invoiceAmount);
}
}
// 认领闸门:确认前按票号重查金蝶复核可用性,选中到确认之间票被认领/使用/红冲在此拦截
invoiceReceiptService.assertInvoicesClaimable(invoiceNumbers);
if (matchedTotal.compareTo(money(settlement.getSettlementAmount())) > 0) {
throw new ServiceException("发票匹配结算单金额合计不能超过结算金额");
}
// 先票后款:认领金额=发票全额,发票合计可超过结算金额(发票金额与结算金额本就是两个口径),
// 不再校验"匹配合计不能超过结算金额";收票状态以是否覆盖结算金额判定(invoiceStatus)
List<FormalSettlementInvoice> existingInvoices = invoiceMapper.selectList(
Wrappers.<FormalSettlementInvoice>lambdaQuery().eq(FormalSettlementInvoice::getFormalSettlementId, settlement.getId()));
Set<String> existingNumbers = existingInvoices.stream().map(FormalSettlementInvoice::getInvoiceNo)
@@ -1357,6 +1572,13 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
settlement.setInvoiceAmount(money(settlement.getInvoiceAmount()).add(insertedMatchedTotal));
settlement.setInvoiceStatus(invoiceStatus(settlement.getInvoiceAmount(), settlement.getSettlementAmount()));
updateById(settlement);
// 先票后款:同步收票认领关系(一票一结算单校验 + 重建收票登记侧关系 + 刷新收票状态);
// 认领为增量添加,关系须覆盖存量+本次新增的发票号
if ("payable".equals(settlement.getSettlementType())) {
Set<String> allInvoiceNos = new LinkedHashSet<>(existingNumbers);
allInvoiceNos.addAll(invoiceNumbers);
invoiceReceiptService.syncSettlementReceiptRelations(settlement.getId(), new ArrayList<>(allInvoiceNos));
}
}
private String createPayment(FormalSettlement settlement, BigDecimal appliedAmount, String remark) {
@@ -1804,9 +2026,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
matchedTotal = matchedTotal.add(matchedAmount);
}
if (matchedTotal.compareTo(money(settlement.getSettlementAmount())) > 0) {
throw new ServiceException("发票匹配结算单金额合计不能超过结算金额");
}
// 先票后款:认领金额=发票全额,发票合计可超过结算金额(发票金额与结算金额本就是两个口径),
// 不再校验"匹配合计不能超过结算金额";收票状态以是否覆盖结算金额判定(invoiceStatus)
invoiceMapper.delete(Wrappers.<FormalSettlementInvoice>lambdaQuery()
.eq(FormalSettlementInvoice::getFormalSettlementId, settlement.getId()));
int lineNo = 1;
@@ -1827,6 +2048,10 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
settlement.setInvoiceAmount(money(matchedTotal));
settlement.setInvoiceStatus(invoiceStatus(matchedTotal, settlement.getSettlementAmount()));
updateById(settlement);
// 先票后款:保存/编辑按发票行同步收票认领关系(一票一结算单校验 + 重建收票登记侧关系 + 刷新收票状态)
if ("payable".equals(settlement.getSettlementType())) {
invoiceReceiptService.syncSettlementReceiptRelations(settlement.getId(), new ArrayList<>(invoiceNumbers));
}
}
private void refreshSettlementAmount(FormalSettlement settlement) {
@@ -2138,7 +2363,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.orderByAsc(FormalSettlementDetailFee::getLineNo)).stream()
.collect(Collectors.groupingBy(FormalSettlementDetailFee::getFormalSettlementDetailId, LinkedHashMap::new, Collectors.toList()));
for (FormalSettlementDetail detail : details) {
Waybill waybill = waybillMap.get(detail.getWaybillId());
// waybillMap 为不可变空 Map(全部明细均无运单号时),get(null) 会抛 NPE,需先判空
Waybill waybill = detail.getWaybillId() == null ? null : waybillMap.get(detail.getWaybillId());
LinkedHashSet<String> goodsNames = new LinkedHashSet<>();
LinkedHashSet<String> goodsTypes = new LinkedHashSet<>();
if (waybill != null) {
@@ -2389,7 +2615,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
private String invoiceStatus(BigDecimal invoiceAmount, BigDecimal settlementAmount) {
BigDecimal matchedAmount = money(invoiceAmount);
if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0) return "unreceived";
return matchedAmount.compareTo(money(settlementAmount)) == 0 ? "completed" : "partial";
// 先票后款全额认领:匹配金额=发票全额,可超过结算金额,覆盖即视为已完成收票
return matchedAmount.compareTo(money(settlementAmount)) >= 0 ? "completed" : "partial";
}
private record PaymentSummary(BigDecimal appliedAmount, BigDecimal paidAmount) {
@@ -43,6 +43,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveEntryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveFreightDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyOpenApiBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyOpenApiInvoiceVO;
@@ -733,9 +734,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
public String doSyncKingdee(Long id) {
InvoiceApplication entity = lockedExisting(id);
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶");
// 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
|| KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(entity.getKingdeeStatus())) {
// 已开票为终态幂等返回;已同步且星瀚内码齐备幂等返回——
// 已同步但内码缺失(历史推送未落内码)放行,走下方 A303-4 勾稽反查补内码,供 A902 附件上传定位金蝶单据
if (KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(entity.getKingdeeStatus())
|| (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
&& Func.isNotBlank(entity.getKingdeeBillId()))) {
return entity.getKingdeeBillNo();
}
if (Func.isBlank(entity.getApplicationNo())) throw new ServiceException("推送金蝶开票申请失败:开票申请单号为空");
@@ -782,31 +785,40 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
// 每张发票的金蝶业务单据编号(A303-1 billno 候选键,须唯一):单张=开票申请单号(兼容存量),多张=开票申请单号-张次序号
List<String> sheetBillNos = sheets.stream().map(sheet -> this.sheetBillNo(entity, sheet, sheets.size())).toList();
// A303-4 幂等预检:按全部候选 billno 查询金蝶是否已生成开票申请单(防上次推送成功但本地回滚的断链重推),
// 已生成的张次幂等跳过,未生成的张次重新推送
// 实测返回 billno 与推送值完全一致,按 billno 精确匹配;部分张次已生成时不可整单提前返回,须断点续推剩余张次
BfiInvoiceApplyQueryDTO idempotentQuery = new BfiInvoiceApplyQueryDTO();
idempotentQuery.setPageNo(1);
idempotentQuery.setPageSize(Math.max(sheets.size(), 10));
idempotentQuery.setBillno(sheetBillNos);
Set<String> existSheetBillNos = new LinkedHashSet<>();
Map<String, BfiInvoiceApplyVO> existApplyByBillNo = new LinkedHashMap<>();
BfiPageVO<BfiInvoiceApplyVO> existPage = bfiService.queryInvoiceApply(idempotentQuery);
if (existPage != null && Func.isNotEmpty(existPage.getRows())) {
for (BfiInvoiceApplyVO existApply : existPage.getRows()) {
if (existApply != null && Func.isNotBlank(existApply.getBillno())
&& sheetBillNos.contains(existApply.getBillno())) {
existSheetBillNos.add(existApply.getBillno());
// 金蝶已生成:直接回填视为同步成功(勾稽纠偏),星瀚内码一并回填(A902 附件上传定位用)
log.info("开票申请[{}]经 A303-4 查得金蝶开票申请单已生成(单据编号[{}],星瀚内码[{}]),跳过重复推送直接回填",
existApplyByBillNo.put(existApply.getBillno(), existApply);
log.info("开票申请[{}]经 A303-4 查得金蝶开票申请单已生成(单据编号[{}],星瀚内码[{}]),幂等跳过推送",
entity.getApplicationNo(), existApply.getBillno(), existApply.getId());
entity.setKingdeeBillNo(existApply.getBillno());
entity.setKingdeeBillId(existApply.getId() == null ? null : String.valueOf(existApply.getId()));
entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
entity.setSyncedTime(LocalDateTime.now());
updateById(entity);
record(entity.getId(), "sync", "同步金蝶(勾稽回填)", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, null, existApply.getBillno());
return existApply.getBillno();
}
}
}
// 全部张次金蝶均已生成:直接勾稽回填视为同步成功(星瀚内码一并回填,A902 附件上传定位用)
if (!existApplyByBillNo.isEmpty() && existApplyByBillNo.keySet().containsAll(sheetBillNos)) {
entity.setKingdeeBillNo(String.join(",", existApplyByBillNo.keySet()));
entity.setKingdeeBillId(existApplyByBillNo.values().stream()
.map(BfiInvoiceApplyVO::getId).filter(Objects::nonNull).findFirst().map(String::valueOf).orElse(null));
entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
entity.setSyncedTime(LocalDateTime.now());
updateById(entity);
record(entity.getId(), "sync", "同步金蝶(勾稽回填)", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, null, entity.getKingdeeBillNo());
return entity.getKingdeeBillNo();
}
// 部分张次已生成:先回填星瀚内码,已生成张次在下方逐张循环幂等跳过,未生成张次继续推送
Set<String> existSheetBillNos = existApplyByBillNo.keySet();
if (Func.isBlank(entity.getKingdeeBillId())) {
entity.setKingdeeBillId(existApplyByBillNo.values().stream()
.map(BfiInvoiceApplyVO::getId).filter(Objects::nonNull).findFirst().map(String::valueOf).orElse(null));
}
// 组装 A303-1 开票申请单(字段映射见设计文档 6.3/6.4)
if (Func.isBlank(entity.getIssuerName())) {
throw new ServiceException("推送金蝶开票申请失败:开票单位(销方)为空");
@@ -879,7 +891,12 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param);
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult,
"推送金蝶开票申请发票张[" + Func.toStr(sheet.getSheetNo()) + "]");
String kingdeeNo = saveResult.getResult().get(0).getNumber();
BfiBillSaveResultItemVO saveItem = saveResult.getResult().get(0);
String kingdeeNo = saveItem.getNumber();
// 星瀚内码随 A303-1 保存结果回填(A902 附件上传定位金蝶单据用),缺失时由 A303-4 勾稽反查兜底
if (Func.isBlank(entity.getKingdeeBillId()) && saveItem.getId() != null) {
entity.setKingdeeBillId(String.valueOf(saveItem.getId()));
}
sheet.setKingdeeBillNo(kingdeeNo);
savedBillNos.add(kingdeeNo);
pushedNew = true;
@@ -51,21 +51,21 @@ 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.config.BfiProperties;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.event.KingdeeBillApprovedEvent;
import org.springblade.transport.mapper.CustomerArchiveMapper;
import org.springblade.transport.mapper.CustomerContactMapper;
import org.springblade.transport.mapper.CustomerInvoiceInfoMapper;
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
import org.springblade.transport.mapper.FormalSettlementInvoiceMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.InvoiceReceiptMapper;
import org.springblade.transport.mapper.InvoiceReceiptRecordMapper;
import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper;
import org.springblade.transport.mapper.CustomerInvoiceInfoMapper;
import org.springblade.transport.mapper.KingdeeInvoicePoolMapper;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
@@ -76,6 +76,7 @@ import org.springblade.transport.pojo.entity.CustomerContact;
import org.springblade.transport.pojo.entity.CustomerInvoiceInfo;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.FormalSettlementInvoice;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
import org.springblade.transport.pojo.entity.InvoiceReceiptRecord;
import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement;
@@ -89,7 +90,6 @@ import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
import org.springblade.transport.support.KingdeeReceivedInvoiceSyncSupport;
import org.springblade.transport.support.KingdeeSyncLockSupport;
import org.springblade.transport.wrapper.InvoiceReceiptWrapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@@ -107,6 +107,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -396,12 +397,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
KingdeeInvoicePool invoice = persistMirrorFromRemote(request.getInvoiceNo());
assertInvoiceUnused(invoice, entity.getId());
// 先票后款链路:settlements 不传=不挂结算单(认领关系只由付款结算单侧维护);传了走历史兼容链路
Map<Long, InvoiceReceiptSaveRequest.SettlementRow> requestedRows = distinctSettlementRows(
request.getSettlements());
Map<Long, FormalSettlement> settlementMap = lockSettlements(requestedRows.keySet().stream().sorted().toList());
List<FormalSettlement> settlements = requestedRows.keySet().stream().map(settlementMap::get).toList();
assertCompatible(settlements);
// 历史兼容链路(传 settlements)保留原整体一致性校验;先票后款链路(不传)跳过
if (!requestedRows.isEmpty()) {
assertCompatible(settlements);
}
for (Map.Entry<Long, InvoiceReceiptSaveRequest.SettlementRow> entry : requestedRows.entrySet()) {
FormalSettlement settlement = settlementMap.get(entry.getKey());
BigDecimal allocated = nonNegative(entry.getValue().getAllocatedInvoiceAmount(), "分摊发票金额");
@@ -418,13 +422,32 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
entity.setCurrentProcessor(AuthUtil.getUserName());
}
copyInvoiceInformation(entity, invoice);
FormalSettlement first = settlements.get(0);
entity.setProjectId(first.getProjectId());
entity.setProjectName(first.getProjectName());
entity.setDeptId(first.getDeptId());
entity.setDeptName(first.getDeptName());
entity.setPayerName(first.getPayerName());
entity.setPayeeName(first.getPayeeName());
if (requestedRows.isEmpty()) {
// 先票后款:结算组织=页面手选部门(合同承办部门口径);付款方=受票单位、收款方=开票单位
if (request.getDeptId() == null) {
throw new ServiceException("请选择结算组织");
}
entity.setDeptId(request.getDeptId());
entity.setDeptName(request.getDeptName());
entity.setPayerName(invoice.getReceiverName());
entity.setPayeeName(invoice.getIssuerName());
// 往来户=发票销售方金蝶供应商代码(A901-1),保存时尽力回填,推送前强校验缺失阻断
if (Func.isBlank(entity.getSupplierCode())) {
String supplierCode = kingdeeApBillSyncSupport.queryCustomerCode(invoice.getIssuerName());
entity.setSupplierCode(supplierCode);
if (Func.isBlank(supplierCode)) {
log.warn("收票登记[{}]销售方[{}]未建档金蝶供应商编码,推送前须先维护客商档案", invoice.getInvoiceNo(), invoice.getIssuerName());
}
}
} else {
FormalSettlement first = settlements.get(0);
entity.setProjectId(first.getProjectId());
entity.setProjectName(first.getProjectName());
entity.setDeptId(first.getDeptId());
entity.setDeptName(first.getDeptName());
entity.setPayerName(first.getPayerName());
entity.setPayeeName(first.getPayeeName());
}
entity.setPhone(limit(Func.isNotEmpty(request.getPhone()) ? request.getPhone() : invoice.getPhone(),
50, "电话"));
entity.setCustomerEmails(normalizeEmails(Func.isNotEmpty(request.getCustomerEmails())
@@ -435,12 +458,17 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
entity.setRemark(limit(request.getRemark(), 200, "备注"));
saveOrUpdate(entity);
settlementRelationMapper.delete(Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getInvoiceReceiptId, entity.getId()));
saveRelations(entity.getId(), requestedRows, settlementMap);
Set<Long> refreshIds = new LinkedHashSet<>(oldSettlementIds);
refreshIds.addAll(settlementMap.keySet());
refreshIds.forEach(this::refreshSettlementInvoiceStatus);
if (requestedRows.isEmpty()) {
// 未传 settlements:不动既有分摊关系(先票后款链路,关系只由结算单侧重建维护)
log.info("收票登记[{}]保存未关联结算单(先票后款链路),既有分摊关系保持不动", entity.getInvoiceNo());
} else {
settlementRelationMapper.delete(Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getInvoiceReceiptId, entity.getId()));
saveRelations(entity.getId(), requestedRows, settlementMap);
Set<Long> refreshIds = new LinkedHashSet<>(oldSettlementIds);
refreshIds.addAll(settlementMap.keySet());
refreshIds.forEach(this::refreshSettlementInvoiceStatus);
}
record(entity.getId(), creating ? "create" : "save", creating ? "创建草稿" : "保存草稿",
entity.getApprovalStatus(), entity.getApprovalStatus(), null, entity.getKingdeeReceivedInvoiceNo());
return entity.getId();
@@ -536,11 +564,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/**
* 批量推送收票登记至金蝶(图05 环节②③:A303-2 收票单批量保存 + A302-6 指定应付)。
* 批量推送收票登记至金蝶(A303-2 收票单批量保存,先票后款:推送即终态 pushed,A302-6 指定应付移交付款结算单侧)。
* <p>
* 编排流程:参数去重校验 → 批量组装推送计划(分摊/镜像/结算单/结算明细一次取回防 N+1)→
* A102-4 应付单已生成预检 → A801-3 幂等预检(金蝶已有收票单则跳过保存续推指定)→
* 逐单推送(单笔失败不中断整批)→ 汇总成功/失败笔数返回
* 编排流程:参数去重校验 → 批量组装推送计划(镜像一次取回防 N+1)→
* A801-3 幂等预检(金蝶已有收票单则跳过保存)→ 逐单推送(单笔失败不中断整批)→ 汇总成功/失败笔数返回
*
* @param ids 收票登记主键集合
* @param autoSync 自动推送路径(审批通过事件/定时重推):前置拦截置 failed + 原因纳入重推循环;
@@ -561,56 +588,23 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
if (receipts.size() != distinctIds.size()) {
throw new ServiceException("部分收票登记不存在或已删除");
}
// 批量组装推送计划:分摊关系/镜像/结算单/结算明细一次取回,避免逐单查库 N+1
// 1) 收票登记与正式结算单的分摊关系(按登记id分组),用于"一票一应付"校验及防重键取数来源
Map<Long, List<InvoiceReceiptSettlement>> relationsByReceipt = settlementRelationMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.in(InvoiceReceiptSettlement::getInvoiceReceiptId, distinctIds)
.orderByAsc(InvoiceReceiptSettlement::getCreateTime)).stream()
.collect(Collectors.groupingBy(InvoiceReceiptSettlement::getInvoiceReceiptId));
// 2) 票池镜像(A801-1 同步落库的发票数据),提供明细行 itemsJson、票种编码、购方单位等推送必填字段
// 批量组装推送计划:票池镜像一次取回防 N+1(先票后款:不再依赖分摊结算单/结算明细与 A102-4 应付单预检)
List<Long> mirrorIds = receipts.stream().map(InvoiceReceipt::getKingdeeInvoicePoolId)
.filter(Objects::nonNull).distinct().toList();
Map<Long, KingdeeInvoicePool> mirrorById = mirrorIds.isEmpty() ? Map.of()
: invoicePoolMapper.selectBatchIds(mirrorIds).stream()
.collect(Collectors.toMap(KingdeeInvoicePool::getId, Function.identity(), (first, second) -> first));
// 3) 分摊关系指向的正式结算单,后续校验须为审批通过的应付类型结算单
List<Long> settlementIds = relationsByReceipt.values().stream().flatMap(List::stream)
.map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList();
Map<Long, FormalSettlement> settlementById = settlementIds.isEmpty() ? Map.of()
: formalSettlementMapper.selectBatchIds(settlementIds).stream()
.collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, second) -> first));
// 4) 应付结算单的明细行(按行号升序),用于组装 A302-6 指定应付的防重键 apBillSrcCode
List<Long> payableSettlementIds = settlementById.values().stream()
.filter(settlement -> "payable".equals(settlement.getSettlementType()))
.map(FormalSettlement::getId).toList();
Map<Long, List<FormalSettlementDetail>> detailsBySettlement = payableSettlementIds.isEmpty() ? Map.of()
: formalSettlementDetailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.in(FormalSettlementDetail::getFormalSettlementId, payableSettlementIds)
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo)).stream()
.collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId));
log.info("推送金蝶收票单批量取数完成:分摊关系 {} 条,票池镜像 {} 个,结算单 {} 张(应付 {} 张),结算明细 {} 行",
relationsByReceipt.size(), mirrorById.size(), settlementById.size(), payableSettlementIds.size(),
detailsBySettlement.values().stream().mapToInt(List::size).sum());
// 5) 逐单组装推送计划:状态机/绑定/查验/一票一应付/防重键校验,不满足项写入 blockedReason 不抛异常(逐单报因)
log.info("推送金蝶收票单批量取数完成:票池镜像 {} 个", mirrorById.size());
// 逐单组装推送计划:状态机/绑定/查验/结算组织校验,不满足项写入 blockedReason 不抛异常(逐单报因)
Map<Long, ReceivedInvoicePushPlan> planByReceipt = new LinkedHashMap<>();
for (InvoiceReceipt receipt : receipts) {
planByReceipt.put(receipt.getId(), buildReceivedInvoicePushPlan(receipt,
relationsByReceipt.getOrDefault(receipt.getId(), List.of()), mirrorById, settlementById, detailsBySettlement));
planByReceipt.put(receipt.getId(), buildReceivedInvoicePushPlan(receipt, mirrorById));
}
long blockedCount = planByReceipt.values().stream().filter(plan -> plan.blockedReason() != null).count();
log.info("推送金蝶收票单计划组装完成:本批 {} 笔,前置校验拦截 {} 笔", planByReceipt.size(), blockedCount);
// A102-4 应付单已生成预检:本批防重键一次批量查询,缺单的登记逐单提示先完成付款侧同步
List<String> apSrcCodes = planByReceipt.values().stream()
.filter(plan -> plan.blockedReason() == null)
.map(ReceivedInvoicePushPlan::apBillSrcCode).filter(Func::isNotBlank).distinct().toList();
Map<String, String> apBillNoBySrcCode = apSrcCodes.isEmpty() ? Map.of()
: kingdeeApBillSyncSupport.queryApBillExistNos(apSrcCodes);
log.info("推送金蝶收票单 A102-4 预检:待校验防重键 {} 个,已生成财务应付单 {} 个", apSrcCodes.size(), apBillNoBySrcCode.size());
// A801-3 幂等预检:按购方单位(接口必填)分组回看窗口翻页,本地按防重键匹配(金蝶已存在则跳过保存续推指定)
// A801-3 幂等预检:按购方单位(接口必填)分组回看窗口翻页,本地按防重键匹配(金蝶已存在则跳过保存)
Map<String, BfiReceivedInvoiceVO> kingdeeRowByInvoiceNo = queryKingdeeReceivedInvoiceRows(planByReceipt);
log.info("推送金蝶收票单 A801-3 预检:金蝶已存在收票单 {} 笔,命中单据将跳过保存续推指定", kingdeeRowByInvoiceNo.size());
log.info("推送金蝶收票单 A801-3 预检:金蝶已存在收票单 {} 笔,命中单据将跳过保存", kingdeeRowByInvoiceNo.size());
KingdeeSyncContext context = KingdeeSyncContext.empty();
int success = 0;
List<String> failures = new ArrayList<>();
@@ -618,7 +612,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
try {
// 逐单单据级事务:推送方法首行 FOR UPDATE 串行化同一登记的并发推送,单笔失败只回滚自身不中断整批
transactionTemplate.executeWithoutResult(status -> pushOneReceivedInvoice(
planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync));
planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, context, autoSync));
success++;
} catch (RuntimeException pushException) {
failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId()))
@@ -672,20 +666,19 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/**
* 单张收票登记推送编排:前置校验 → A102-4 预检 → A801-3 幂等预检 → A303-2 保存 → pushed 落库 →
* A302-6 指定应付 → assigned;保存/指定阶段失败置 failed,断点可凭防重键续推。
* 单张收票登记推送编排:前置校验 → A801-3 幂等预检 → A303-2 保存 → pushed 终态落库(先票后款:
* A302-6 指定应付由付款结算单侧在财务应付单生成后按认领关系执行)。
* 自动推送路径(autoSync=true)前置拦截一并置 failed + 原因,纳入定时重推循环;
* 手动批量路径前置拦截保持不动状态(逐单报因),避免人工误触把可自助修复的单据打成失败
*
* @param plan 预检组装的推送计划
* @param kingdeeRows A801-3 幂等预检命中的金蝶收票单(发票号码 -&gt; 行)
* @param apBillNoBySrcCode A102-4 已生成的财务应付单(防重键 -&gt; 单据编号)
* @param context 金蝶同步上下文(合同/客商编码缓存)
* @param autoSync 自动推送路径标记(前置拦截置 failed)
* @param plan 预检组装的推送计划
* @param kingdeeRows A801-3 幂等预检命中的金蝶收票单(发票号码 -&gt; 行)
* @param context 金蝶同步上下文(客商编码缓存)
* @param autoSync 自动推送路径标记(前置拦截置 failed)
* @return 推送结果描述
*/
private String pushOneReceivedInvoice(ReceivedInvoicePushPlan plan, Map<String, BfiReceivedInvoiceVO> kingdeeRows,
Map<String, String> apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) {
KingdeeSyncContext context, boolean autoSync) {
// 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启)
InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.<InvoiceReceipt>lambdaQuery()
.eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE"));
@@ -701,8 +694,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
// 计划内登记实体以行锁后最新数据替换(并发下推送状态可能已被其他事务改写)
plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.settlement(), plan.details(),
plan.apBillSrcCode(), plan.blockedReason());
log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(),
receipt.getKingdeePushStatus(), plan.apBillSrcCode());
log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={}", receipt.getInvoiceNo(), receipt.getId(),
receipt.getKingdeePushStatus());
if (Func.isNotBlank(plan.blockedReason())) {
log.warn("收票登记[{}]推送金蝶收票单拦截:{}", receipt.getInvoiceNo(), plan.blockedReason());
// 自动推送路径:前置不满足置 failed + 原因纳入重推循环("已指定应付"的良性跳过不改状态)
@@ -713,19 +706,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
throw new ServiceException(plan.blockedReason());
}
String apBillSrcCode = plan.apBillSrcCode();
if (!apBillNoBySrcCode.containsKey(apBillSrcCode)) {
log.warn("收票登记[{}]推送金蝶收票单拦截:结算单[{}]未生成财务应付单(防重键={})", receipt.getInvoiceNo(),
plan.settlement().getFormalSettlementNo(), apBillSrcCode);
// 自动推送路径:前置应付单未生成置 failed + 原因,上游结算单就绪后由重推任务自动续推
if (autoSync) {
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("请先同步金蝶应付单:结算单" + plan.settlement().getFormalSettlementNo() + "未生成财务应付单"), true);
}
throw new ServiceException("请先同步金蝶应付单:结算单" + plan.settlement().getFormalSettlementNo()
+ "未生成财务应付单");
}
String billNo;
String billPkId;
BfiReceivedInvoiceVO kingdeeRow = kingdeeRows.get(String.valueOf(receipt.getId()));
@@ -745,13 +725,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
// 幂等预检命中行携带星瀚收票单内码,回填补齐(A902 附件上传定位用)
billPkId = kingdeeRow.getId() == null ? receipt.getKingdeeReceivedInvoiceId()
: String.valueOf(kingdeeRow.getId());
log.info("收票登记[{}]金蝶收票单已生成(单据编号[{}]),幂等跳过 A303-2 保存续推指定", receipt.getInvoiceNo(), billNo);
log.info("收票登记[{}]金蝶收票单已生成(单据编号[{}]),幂等跳过 A303-2 保存", receipt.getInvoiceNo(), billNo);
} else {
try {
// 镜像缺票种编码(入镜像早于字段上线或滑出增量窗口):按票号窄查 A801-1 回填补写;
// 明细分录已与应付单同源取自结算货物费用行,票池明细行仅入池存档、不再作为分录取数来源
if (Func.isBlank(plan.mirror().getInvoiceTypeCode())) {
log.info("收票登记[{}]镜像缺票种编码,按票号回填 A801-1", receipt.getInvoiceNo());
// 镜像缺票种编码/明细行(入镜像早于字段上线或滑出增量窗口):按票号窄查 A801-1 回填补写;
// 先票后款链路明细分录逐行取发票明细行(镜像 items_json),必须齐备
if (Func.isBlank(plan.mirror().getInvoiceTypeCode()) || Func.isBlank(plan.mirror().getItemsJson())) {
log.info("收票登记[{}]镜像缺票种编码/明细行,按票号回填 A801-1", receipt.getInvoiceNo());
fetchMirrorItemsFromPool(plan.mirror());
}
log.info("收票登记[{}]开始 A303-2 推送金蝶收票单", receipt.getInvoiceNo());
@@ -772,23 +752,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
throw pushException;
}
}
// 保存成功先落单号+星瀚内码+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false);
// 先票后款:A303-2 保存成功即终态 pushed(A302-6 指定应付由付款结算单侧在应付单生成后执行,此处不再立即指定)
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, true);
// A303-2 成功即联带上传附件(登记附件 + 镜像发票文件经 A801-2 下载回传):
// 失败只记附件同步状态,不阻塞 A302-6 指定应付,也不阻塞同批其他登记,重推自动补传
// 失败只记附件同步状态,不阻塞同批其他登记,重推自动补传
syncAttachmentsAfterPush(receipt, plan, billPkId);
try {
log.info("收票登记[{}]开始 A302-6 指定应付:应付单防重键={}", receipt.getInvoiceNo(), apBillSrcCode);
kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode);
} catch (RuntimeException assignException) {
log.warn("收票登记[{}]A302-6 指定应付失败:{}", receipt.getInvoiceNo(), assignException.getMessage());
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("金蝶收票单指定应付失败:" + assignException.getMessage()), true);
throw assignException;
}
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
log.info("收票登记[{}]推送金蝶收票单完成:金蝶收票单[{}]已指定应付单", receipt.getInvoiceNo(), billNo);
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单";
log.info("收票登记[{}]推送金蝶收票单完成:金蝶收票单[{}]已保存(指定应付由付款结算单侧执行)", receipt.getInvoiceNo(), billNo);
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已保存收票单";
}
/**
@@ -843,13 +813,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/**
* 推送前置组装:状态机、绑定、查验、一票一应付、应付单防重键;不满足项写入 blockedReason(不拦截整批,逐单报因)
* 推送前置组装:状态机、绑定、查验、结算组织;不满足项写入 blockedReason(不拦截整批,逐单报因)。
* 先票后款链路:不再校验分摊结算单(一票一应付的指定前置移至结算单侧),往来户供应商代码缺失由
* 推送时实时查 A901-1 兜底,不做前置拦截
*/
private ReceivedInvoicePushPlan buildReceivedInvoicePushPlan(InvoiceReceipt receipt,
List<InvoiceReceiptSettlement> relations,
Map<Long, KingdeeInvoicePool> mirrorById,
Map<Long, FormalSettlement> settlementById,
Map<Long, List<FormalSettlementDetail>> detailsBySettlement) {
private ReceivedInvoicePushPlan buildReceivedInvoicePushPlan(InvoiceReceipt receipt, Map<Long, KingdeeInvoicePool> mirrorById) {
String pushStatus = Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED);
if (!TransportBusiConstant.APPROVED.equals(receipt.getApprovalStatus())) {
return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "仅审批通过的收票登记允许推送金蝶");
@@ -865,24 +833,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
if ("2".equals(mirror.getCheckStatus())) {
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "金蝶发票查验不通过,请先人工核实票池发票");
}
if (relations.size() != 1 || relations.get(0).getFormalSettlementId() == null) {
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null,
"金蝶不支持一张发票指定多张应付单,收票登记须恰好分摊一张应付正式结算单");
if (receipt.getDeptId() == null) {
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "收票登记未选择结算组织,无法确定金蝶结算组织编码");
}
FormalSettlement settlement = settlementById.get(relations.get(0).getFormalSettlementId());
if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)
|| !Objects.equals(settlement.getStatus(), 1)
|| !TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())
|| !"payable".equals(settlement.getSettlementType())) {
return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, null, "分摊结算单不是审批通过的应付正式结算单");
}
List<FormalSettlementDetail> details = detailsBySettlement.getOrDefault(settlement.getId(), List.of());
if (details.isEmpty()) {
return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, null,
"结算单" + settlement.getFormalSettlementNo() + "无结算明细,无法组装应付单防重键");
}
return new ReceivedInvoicePushPlan(receipt, mirror, settlement, details,
kingdeeApBillSyncSupport.apBillSrcCode(settlement, details), null);
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, null);
}
/** A801-3 幂等预检批量查询:未终态计划按购方单位分组,窗口内翻页匹配防重键 */
@@ -909,7 +863,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/**
* 推送/回写结果落库:回写金蝶收票单号、星瀚收票单内码、推送状态、失败原因与时间;写终态(assigned/failed)时记同步流水
* 推送/回写结果落库:回写金蝶收票单号、星瀚收票单内码、推送状态、失败原因与时间;写终态(pushed/assigned/failed)时记同步流水
*/
private void applyReceivedInvoicePushResult(InvoiceReceipt receipt, String receivedInvoiceNo, String receivedInvoiceId,
String pushStatus, String pushMsg, boolean writeRecord) {
@@ -923,7 +877,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
updateById(receipt);
if (writeRecord) {
record(receipt.getId(), "sync", "推送金蝶收票单", receipt.getApprovalStatus(), receipt.getApprovalStatus(),
Func.isNotBlank(pushMsg) ? pushMsg : "金蝶收票单[" + Func.toStr(receivedInvoiceNo) + "]已生成并指定应付",
Func.isNotBlank(pushMsg) ? pushMsg : "金蝶收票单[" + Func.toStr(receivedInvoiceNo) + "]已生成",
receipt.getKingdeeReceivedInvoiceNo());
}
}
@@ -959,7 +913,203 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
* @param blockedReason 前置拦截原因
*/
private record ReceivedInvoicePushPlan(InvoiceReceipt receipt, KingdeeInvoicePool mirror,
FormalSettlement settlement, List<FormalSettlementDetail> details, String apBillSrcCode, String blockedReason) {
FormalSettlement settlement, List<FormalSettlementDetail> details, String apBillSrcCode, String blockedReason) {
}
// ==================== 先票后款:发票明细查询 / 认领候选 / 结算侧关系同步与指定应付 ====================
@Override
public List<BfiInvoicePoolItemVO> invoiceDetailQuery(String invoiceNo) {
if (StringUtil.isBlank(invoiceNo)) {
throw new ServiceException("请填写发票号码");
}
String trimmedInvoiceNo = invoiceNo.trim();
KingdeeInvoicePool mirror = invoicePoolMapper.selectOne(Wrappers.<KingdeeInvoicePool>lambdaQuery()
.eq(KingdeeInvoicePool::getInvoiceNo, trimmedInvoiceNo));
if (mirror != null) {
if (Func.isBlank(mirror.getItemsJson())) {
log.info("发票[{}]镜像明细为空,按票号回查 A801-1 补写", trimmedInvoiceNo);
fetchMirrorItemsFromPool(mirror);
}
List<BfiInvoicePoolItemVO> items = this.parseMirrorItems(trimmedInvoiceNo, mirror.getItemsJson());
if (Func.isNotEmpty(items)) {
return items;
}
}
// 无镜像或补查后仍无明细:直接取窄查返回行的明细(票未登记的明细预览场景)
BfiInvoicePoolVO poolRow = queryPoolByInvoiceNo(trimmedInvoiceNo).stream()
.filter(row -> Func.isNotEmpty(row.getInvoiceNo()) && trimmedInvoiceNo.equals(row.getInvoiceNo().trim()))
.findFirst().orElse(null);
if (poolRow == null) {
throw new ServiceException("金蝶票池未查询到发票 " + trimmedInvoiceNo + ",无法查询发票明细");
}
return Func.isEmpty(poolRow.getItems()) ? List.of() : poolRow.getItems();
}
@Override
public IPage<InvoiceReceipt> claimCandidates(String keyword, IPage<InvoiceReceipt> page) {
String trimmedKeyword = StringUtil.isBlank(keyword) ? null : keyword.trim();
// 排除已被有效应付结算单(未删除+启用+应付类型)认领的票,关系仍指向作废结算单的可重新选
String claimedReceiptSubSql = "SELECT r.invoice_receipt_id FROM blade_invoice_receipt_settlement r"
+ " INNER JOIN blade_formal_settlement s ON s.id = r.formal_settlement_id"
+ " WHERE s.is_deleted = 0 AND s.status = 1 AND s.settlement_type = 'payable'";
return page(page, Wrappers.<InvoiceReceipt>lambdaQuery()
.eq(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.APPROVED)
.and(Func.isNotBlank(trimmedKeyword), item -> item.like(InvoiceReceipt::getInvoiceNo, trimmedKeyword)
.or().like(InvoiceReceipt::getIssuerName, trimmedKeyword)
.or().like(InvoiceReceipt::getReceiverName, trimmedKeyword))
.notInSql(InvoiceReceipt::getId, claimedReceiptSubSql)
.orderByDesc(InvoiceReceipt::getCreateTime));
}
@Override
public List<InvoiceReceipt> claimedReceipts(Long settlementId) {
List<InvoiceReceiptSettlement> relations = settlementRelationMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getFormalSettlementId, settlementId));
if (relations.isEmpty()) {
return List.of();
}
return listByIds(relations.stream()
.map(InvoiceReceiptSettlement::getInvoiceReceiptId).filter(Objects::nonNull).distinct().toList());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void syncSettlementReceiptRelations(Long settlementId, List<String> invoiceNos) {
FormalSettlement settlement = formalSettlementMapper.selectById(settlementId);
if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) {
throw new ServiceException("正式结算单不存在,无法同步收票认领关系");
}
List<String> distinctNos = invoiceNos == null ? List.of() : invoiceNos.stream()
.filter(Func::isNotBlank).map(String::trim).distinct().toList();
// 按行发票号匹配审批通过的收票登记(行锁防两结算单并发认领同一票)
List<InvoiceReceipt> matchedReceipts = distinctNos.isEmpty() ? List.of()
: list(Wrappers.<InvoiceReceipt>lambdaQuery()
.in(InvoiceReceipt::getInvoiceNo, distinctNos)
.eq(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.APPROVED)
.last("FOR UPDATE"));
this.checkSingleSettlementClaim(settlement, matchedReceipts);
// 重建本结算单认领关系(删旧建新):分摊金额取发票行的匹配结算单金额(支持手动修改),未匹配到的回退发票全额
Map<String, BigDecimal> matchedByInvoiceNo = formalSettlementInvoiceMapper.selectList(
Wrappers.<FormalSettlementInvoice>lambdaQuery()
.eq(FormalSettlementInvoice::getFormalSettlementId, settlementId))
.stream()
.filter(row -> Func.isNotBlank(row.getInvoiceNo()))
.collect(Collectors.toMap(FormalSettlementInvoice::getInvoiceNo,
row -> money(row.getMatchedAmount()), (left, right) -> left));
settlementRelationMapper.delete(Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getFormalSettlementId, settlementId));
for (InvoiceReceipt receipt : matchedReceipts) {
InvoiceReceiptSettlement relation = new InvoiceReceiptSettlement();
relation.setInvoiceReceiptId(receipt.getId());
relation.setFormalSettlementId(settlementId);
relation.setFormalSettlementNo(settlement.getFormalSettlementNo());
relation.setSettlementAmount(money(settlement.getSettlementAmount()));
relation.setReceivedInvoiceAmount(receivedAmount(settlementId, receipt.getId()));
relation.setAllocatedInvoiceAmount(
matchedByInvoiceNo.getOrDefault(receipt.getInvoiceNo(), money(receipt.getInvoiceAmount())));
settlementRelationMapper.insert(relation);
log.info("结算单[{}]认领收票登记[{}]:分摊金额 {}", settlement.getFormalSettlementNo(),
receipt.getInvoiceNo(), relation.getAllocatedInvoiceAmount());
}
refreshSettlementInvoiceStatus(settlementId);
log.info("结算单[{}]收票认领关系同步完成:匹配收票登记 {} 张(入参发票号 {} 个)",
settlement.getFormalSettlementNo(), matchedReceipts.size(), distinctNos.size());
}
@Override
public String assignClaimedReceipts(Long settlementId, String apBillSrcCode) {
if (Func.isBlank(apBillSrcCode)) {
log.warn("结算单[{}]财务应付单防重键为空,跳过收票单指定应付", settlementId);
return "";
}
List<InvoiceReceiptSettlement> relations = settlementRelationMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getFormalSettlementId, settlementId));
if (relations.isEmpty()) {
log.info("结算单[{}]无认领收票登记,跳过 A302-6 指定应付", settlementId);
return "";
}
List<InvoiceReceipt> receipts = listByIds(relations.stream()
.map(InvoiceReceiptSettlement::getInvoiceReceiptId).filter(Objects::nonNull).distinct().toList());
int assigned = 0;
int skipped = 0;
List<String> failures = new ArrayList<>();
for (InvoiceReceipt receipt : receipts) {
String pushStatus = Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED);
if (!KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(pushStatus)) {
// 未推送(unsynced/failed)或已指定(assigned)均跳过:未推送由 A801-3 回写"指定补偿"按认领关系自动补指定
skipped++;
log.info("结算单[{}]收票登记[{}]推送状态[{}],跳过 A302-6 指定应付", settlementId, receipt.getInvoiceNo(), pushStatus);
continue;
}
try {
kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode);
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(),
KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
assigned++;
log.info("结算单[{}]收票登记[{}]A302-6 指定应付成功:应付单防重键={}", settlementId, receipt.getInvoiceNo(), apBillSrcCode);
} catch (RuntimeException assignException) {
// 单笔失败不阻断结算同步:A801-3 回写"指定补偿"按认领关系自动重试
log.warn("结算单[{}]收票登记[{}]A302-6 指定应付失败(由 A801-3 指定补偿重试)", settlementId, receipt.getInvoiceNo(), assignException);
failures.add("发票" + receipt.getInvoiceNo() + ":" + assignException.getMessage());
}
}
String summary = "结算单收票指定应付:认领 " + receipts.size() + " 笔,指定 " + assigned + " 笔,跳过 " + skipped + " 笔";
if (!failures.isEmpty()) {
summary = summary + ",失败 " + failures.size() + " 笔:" + String.join(";", failures);
}
log.info("{},应付单防重键={}", summary, apBillSrcCode);
return failures.isEmpty() ? "" : String.join(";", failures);
}
/** 票池镜像明细行反序列化:空返回空集,解析异常按脏数据拦截 */
private List<BfiInvoicePoolItemVO> parseMirrorItems(String invoiceNo, String itemsJson) {
if (Func.isBlank(itemsJson)) {
return List.of();
}
try {
BfiInvoicePoolItemVO[] items = JsonUtil.parse(itemsJson, BfiInvoicePoolItemVO[].class);
return items == null ? List.of() : Arrays.asList(items);
} catch (RuntimeException exception) {
log.warn("发票[{}]票池镜像明细行反序列化失败", invoiceNo, exception);
throw new ServiceException("发票[" + invoiceNo + "]票池镜像明细行数据异常,请先按票号补查票池明细");
}
}
/** 一票一结算单校验:认领票已挂其他有效应付结算单时拦截(指向本结算单的旧关系即将重建,不参与校验) */
private void checkSingleSettlementClaim(FormalSettlement settlement, List<InvoiceReceipt> receipts) {
if (receipts.isEmpty()) {
return;
}
List<Long> receiptIds = receipts.stream().map(InvoiceReceipt::getId).toList();
List<InvoiceReceiptSettlement> otherRelations = settlementRelationMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.in(InvoiceReceiptSettlement::getInvoiceReceiptId, receiptIds)
.ne(InvoiceReceiptSettlement::getFormalSettlementId, settlement.getId())).stream()
.filter(relation -> relation.getFormalSettlementId() != null).toList();
if (otherRelations.isEmpty()) {
return;
}
List<Long> otherSettlementIds = otherRelations.stream()
.map(InvoiceReceiptSettlement::getFormalSettlementId).distinct().toList();
Map<Long, FormalSettlement> otherSettlements = formalSettlementMapper.selectBatchIds(otherSettlementIds).stream()
.collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, second) -> first));
for (InvoiceReceipt receipt : receipts) {
for (InvoiceReceiptSettlement relation : otherRelations) {
if (!relation.getInvoiceReceiptId().equals(receipt.getId())) {
continue;
}
FormalSettlement other = otherSettlements.get(relation.getFormalSettlementId());
boolean activeSettlement = other != null && !Objects.equals(other.getIsDeleted(), 1)
&& Objects.equals(other.getStatus(), 1) && "payable".equals(other.getSettlementType());
if (activeSettlement) {
throw new ServiceException("发票" + receipt.getInvoiceNo() + "已被结算单"
+ other.getFormalSettlementNo() + "认领,一张收票单只能被一张付款结算单认领");
}
}
}
}
// ==================== A801-3 收票结果定时回写(异常回收/指定补偿/单号补登) ====================
@@ -974,7 +1124,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
* 金蝶收票单结果回写主体(A801-3):定时轮询推送状态为 pushed/assigned 且未作废的收票登记,
* 按购方单位分组在时间窗口内反查金蝶收票单结果,一体推进三类补偿场景:
* ① 异常回收——金蝶侧发票状态异常时回收推送状态为 failed 并记同步流水,提示人工删除/解除指定后重推;
* ② 指定补偿——pushed(保存成功后中断、指定未完成)按预组装的应付单防重键重发 A302-6 指定应付;
* ② 指定补偿——pushed 且已认领(有应付单防重键)按预组装的应付单防重键重发 A302-6 指定应付(重试引擎),未认领跳过;
* 受 {@code thirdParty.bfi.receivedInvoiceResult.assignCompensationEnabled} 开关控制,关闭后 pushed 单据由人工入口(发票登记推送/重推按钮)指定;
* ③ 单号补登——assigned 终态缺失金蝶收票单号(保存成功但响应丢失的历史数据)时仅补登单号,不改变状态。
* <p>匹配与容错说明:
* ① 匹配键为收票登记 id,与 A303-2 落库的 awrp_src_code 同源,按发票号匹配将永远查不到自推单据;
@@ -987,6 +1138,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
*/
private String executeReceivedInvoiceResultSync() {
long startMillis = System.currentTimeMillis();
boolean assignCompensationEnabled = bfiProperties.getReceivedInvoiceResult().isAssignCompensationEnabled();
List<InvoiceReceipt> pendingReceipts = list(Wrappers.<InvoiceReceipt>lambdaQuery()
.in(InvoiceReceipt::getKingdeePushStatus, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED,
KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED)
@@ -1001,14 +1153,17 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
.filter(receipt -> KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())).count();
log.info("金蝶收票单结果回写开始:待回写收票登记{}笔(pushed待指定补偿 {} 笔,assigned待单号补登 {} 笔)",
pendingReceipts.size(), pushedCount, pendingReceipts.size() - pushedCount);
if (!assignCompensationEnabled) {
log.info("A302-6 指定应付自动补偿已关闭,pushed 单据由人工入口(发票登记推送/重推按钮)指定");
}
List<Long> mirrorIds = pendingReceipts.stream().map(InvoiceReceipt::getKingdeeInvoicePoolId)
.filter(Objects::nonNull).distinct().toList();
Map<Long, KingdeeInvoicePool> mirrorById = invoicePoolMapper.selectBatchIds(mirrorIds).stream()
.collect(Collectors.toMap(KingdeeInvoicePool::getId, Function.identity(), (first, second) -> first));
// pushed(保存成功后中断、指定未完成)集合:批量预组装应付单防重键供指定补偿
List<Long> pushedIds = pendingReceipts.stream()
// pushed(保存成功后中断、指定未完成)集合:批量预组装应付单防重键供指定补偿(补偿关闭时跳过组装)
List<Long> pushedIds = assignCompensationEnabled ? pendingReceipts.stream()
.filter(receipt -> KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus()))
.map(InvoiceReceipt::getId).toList();
.map(InvoiceReceipt::getId).toList() : List.of();
Map<Long, String> apSrcCodeByReceiptId = pushedIds.isEmpty() ? Map.of()
: resolveApBillSrcCodesForWriteBack(pushedIds);
Map<String, List<InvoiceReceipt>> receiptsByBuyer = new LinkedHashMap<>();
@@ -1050,6 +1205,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
continue;
}
if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())) {
if (!assignCompensationEnabled) {
// 补偿开关关闭:不自动重发 A302-6,pushed 保持现状由人工入口指定
log.info("收票登记[{}]指定应付自动补偿已关闭,跳过 A302-6 重发", receipt.getInvoiceNo());
continue;
}
reassignPushedReceivedInvoice(receipt, row, apSrcCodeByReceiptId.get(receipt.getId()));
reassigned++;
continue;
@@ -1076,18 +1236,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return summary;
}
/** pushed 单据指定补偿:金蝶收票单存在且状态正常,按发票防重键+应付防重键重发 A302-6 */
/**
* pushed 单据指定补偿(先票后款"指定补偿"重试引擎):已认领(应付单防重键组装成功)→ 重发 A302-6 → assigned;
* 未认领 → 跳过不再置 failed(指定应付由结算单侧驱动,认领后由结算单同步/发票登记触发,此处未指定不属异常)
*/
private void reassignPushedReceivedInvoice(InvoiceReceipt receipt, BfiReceivedInvoiceVO row, String apBillSrcCode) {
if (StringUtil.isBlank(apBillSrcCode)) {
applyReceivedInvoicePushResult(receipt, row.getBillno(),
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("收票单指定应付补偿失败:收票登记分摊关系不满足一票一应付,请人工核对"), true);
throw new ServiceException("收票单指定应付补偿失败:分摊关系不满足一票一应付");
log.info("收票登记[{}]金蝶收票单[{}]已推送但未认领应付结算单,跳过指定补偿(认领后由结算单侧补指定)",
receipt.getInvoiceNo(), Func.toStrWithEmpty(row.getBillno(), receipt.getKingdeeReceivedInvoiceNo()));
return;
}
try {
kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode);
} catch (RuntimeException assignException) {
} catch (Exception assignException) {
// todo
log.error("收票登记[{}]金蝶收票单[{}]指定应付补偿失败,apBillSrcCode={}", receipt.getInvoiceNo(), row.getBillno(), apBillSrcCode, assignException);
applyReceivedInvoicePushResult(receipt, row.getBillno(),
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
@@ -1573,7 +1736,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
/**
* 镜像票面是否有变化(轻量比对,ponytail: 只比对"会变且影响业务"的字段,
* 不搞人工字段清单——清单漏新增列即静默不同步是旧版指纹的坑):
* 流水号(金蝶侧标识)、价税合计、税额、剩余可用金额、金额使用状态。
* 流水号(金蝶侧标识)、明细行 items_json、价税合计、税额、剩余可用金额、金额使用状态。
* BigDecimal 比对用 compareTo(13.00 与 13.0 等价);空值视为变化跳过重写(与 MP 非空字段更新策略一致)
*
* @param existing 库中既有镜像
@@ -1585,6 +1748,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|| !Objects.equals(existing.getAmountUseStatus(), filled.getAmountUseStatus())) {
return true;
}
// 明细行变化而票面金额未变(单价变总价不变、商品编码修正、行拆合)也要重写:
// items_json 是先票后款链路 A303-2 分录唯一取数来源;源侧无明细返回 null 维持库中原值,不参与比对
if (filled.getItemsJson() != null && !Objects.equals(existing.getItemsJson(), filled.getItemsJson())) {
return true;
}
return amountChanged(existing.getInvoiceAmount(), filled.getInvoiceAmount())
|| amountChanged(existing.getTaxAmount(), filled.getTaxAmount())
|| amountChanged(existing.getAvailableAmount(), filled.getAvailableAmount());
@@ -1852,14 +2020,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
if (request == null) {
throw new ServiceException("收票登记数据不能为空");
}
if (request.getSettlements() == null || request.getSettlements().isEmpty()) {
throw new ServiceException("请至少选择一张应付正式结算单");
}
// 先票后款链路:不再强制关联结算单(settlements 兼容历史入参);不传时结算组织手选必选在 saveDraft 校验
}
private Map<Long, InvoiceReceiptSaveRequest.SettlementRow> distinctSettlementRows(
List<InvoiceReceiptSaveRequest.SettlementRow> rows) {
Map<Long, InvoiceReceiptSaveRequest.SettlementRow> result = new LinkedHashMap<>();
if (rows == null || rows.isEmpty()) {
return result;
}
for (InvoiceReceiptSaveRequest.SettlementRow row : rows) {
if (row == null || row.getSettlementId() == null) {
throw new ServiceException("正式结算单不能为空");
@@ -1965,8 +2134,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
FormalSettlement settlement = formalSettlementMapper.selectById(settlementId);
if (settlement == null) return;
BigDecimal invoiceAmount = receivedAmount(settlementId, null);
// 匹配结算单金额支持手动修改:匹配合计(分摊金额之和)可超过结算金额,覆盖即视为已完成收票
String invoiceStatus = invoiceAmount.compareTo(BigDecimal.ZERO) <= 0 ? "unreceived"
: invoiceAmount.compareTo(money(settlement.getSettlementAmount())) == 0 ? "completed" : "partial";
: invoiceAmount.compareTo(money(settlement.getSettlementAmount())) >= 0 ? "completed" : "partial";
settlement.setInvoiceAmount(invoiceAmount);
settlement.setInvoiceStatus(invoiceStatus);
formalSettlementMapper.updateById(settlement);
@@ -2016,7 +2186,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
.eq(InvoiceReceiptSettlement::getInvoiceReceiptId, entity.getId())
.orderByAsc(InvoiceReceiptSettlement::getCreateTime));
if (relations.isEmpty()) {
throw new ServiceException("请至少选择一张应付正式结算单");
// 先票后款链路:无分摊关系(关系由结算单侧维护),跳过分摊校验
log.info("收票登记[{}]无分摊关系,跳过分摊校验(先票后款链路)", entity.getInvoiceNo());
return;
}
Map<Long, FormalSettlement> settlementMap = lockSettlements(relations.stream()
.map(InvoiceReceiptSettlement::getFormalSettlementId)
@@ -51,6 +51,7 @@ import org.springblade.thirdparty.bfi.util.BfiUtil;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.KingdeeLockConstant;
import org.springblade.transport.constant.TransportBusiConstant;
@@ -64,6 +65,8 @@ import org.springblade.transport.mapper.FormalSettlementDetailMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.FormalSettlementPaymentMapper;
import org.springblade.transport.mapper.FormalSettlementSourceMapper;
import org.springblade.transport.mapper.InvoiceReceiptMapper;
import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper;
import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper;
import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.mapper.PaymentApplicationRecordMapper;
@@ -85,6 +88,8 @@ import org.springblade.transport.pojo.entity.CustomerReceiptAccount;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.FormalSettlementPayment;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementSource;
import org.springblade.transport.pojo.entity.HcsContractExeStage;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
@@ -170,6 +175,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private final BillLedgerMapper billLedgerMapper;
private final BillLedgerUsageMapper billLedgerUsageMapper;
private final TemporaryCreditLimitMapper temporaryCreditLimitMapper;
private final InvoiceReceiptMapper invoiceReceiptMapper;
private final InvoiceReceiptSettlementMapper invoiceReceiptRelationMapper;
private final IFormalSettlementService formalSettlementService;
private final ISettlementProjectAdvanceWriteOffService projectAdvanceWriteOffService;
private final IKingdeeBillLockService kingdeeBillLockService;
@@ -181,6 +188,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private final BladeRedis bladeRedis;
private final ApplicationEventPublisher eventPublisher;
private final HcsCreditCheckSupport hcsCreditCheckSupport;
private final HcsProperties hcsProperties;
private final IContractExeStageService contractExeStageService;
@Override
@@ -208,6 +216,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
});
fillAttachmentSyncStatus(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION, voPage.getRecords());
fillKingdeeLockStatus(voPage.getRecords());
fillKingdeeAssignStatus(voPage.getRecords());
return voPage;
}
@@ -233,6 +242,59 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
}
/**
* 填充已指定应付聚合态:按本页结算单一次批量查认领关系与收票登记,避免 N+1。
* 聚合规则:任一指定失败 → failed;全部已指定 → assigned;其余(有认领但未指定/未推送)→ unspecified;
* 无关联结算单或无有效认领收票不填充,前端展示"-"
*
* @param rows 本页 VO 列表
*/
private void fillKingdeeAssignStatus(List<PaymentApplicationVO> rows) {
Set<Long> settlementIds = rows.stream()
.map(PaymentApplicationVO::getSettlementId).filter(Objects::nonNull).collect(Collectors.toSet());
if (settlementIds.isEmpty()) {
return;
}
List<InvoiceReceiptSettlement> relations = invoiceReceiptRelationMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.in(InvoiceReceiptSettlement::getFormalSettlementId, settlementIds));
Map<Long, List<Long>> receiptIdsBySettlement = relations.stream()
.filter(relation -> relation.getFormalSettlementId() != null && relation.getInvoiceReceiptId() != null)
.collect(Collectors.groupingBy(InvoiceReceiptSettlement::getFormalSettlementId,
Collectors.mapping(InvoiceReceiptSettlement::getInvoiceReceiptId, Collectors.toList())));
List<Long> receiptIds = receiptIdsBySettlement.values().stream()
.flatMap(List::stream).distinct().toList();
Map<Long, InvoiceReceipt> receiptById = receiptIds.isEmpty() ? Map.of()
: invoiceReceiptMapper.selectBatchIds(receiptIds).stream()
.filter(receipt -> !TransportBusiConstant.VOIDED.equals(receipt.getApprovalStatus())
&& !Objects.equals(receipt.getIsDeleted(), 1))
.collect(Collectors.toMap(InvoiceReceipt::getId, receipt -> receipt, (left, right) -> left));
for (PaymentApplicationVO vo : rows) {
List<InvoiceReceipt> claimed = receiptIdsBySettlement.getOrDefault(vo.getSettlementId(), List.of()).stream()
.map(receiptById::get).filter(Objects::nonNull).toList();
if (claimed.isEmpty()) {
continue;
}
List<String> statuses = claimed.stream()
.map(receipt -> Func.toStrWithEmpty(receipt.getKingdeePushStatus(),
KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED))
.toList();
String status = statuses.contains(KingdeeBfiConstant.RECEIVED_PUSH_FAILED) ? "failed"
: statuses.stream().allMatch(KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED::equals)
? "assigned" : "unspecified";
vo.setKingdeeAssignStatus(status);
// 指定失败行聚合各票失败原因,供悬浮展示
if ("failed".equals(status)) {
vo.setKingdeeAssignFailReason(claimed.stream()
.filter(receipt -> KingdeeBfiConstant.RECEIVED_PUSH_FAILED.equals(
Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED)))
.map(receipt -> "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId()))
+ ":" + Func.toStrWithEmpty(receipt.getKingdeePushMsg(), "推送失败"))
.collect(Collectors.joining(";")));
}
}
}
/**
* 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回):
* 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废,
@@ -658,8 +720,9 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
context.paymentNoCache().put(id, entity.getPaymentNo());
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的付款申请允许生成金蝶单据");
// 高风险/黑名单客商付款特批门禁:待特批/特批审核中(挂起"特批审核")禁止推送金蝶,
// 特批通过(合规 paymentLegal/result 回调恢复"审批通过")后放行
if (entity.getHcsSpecStatus() != null && List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PUSHING,
// 特批通过(合规 paymentLegal/result 回调恢复"审批通过")后放行;
// 特批不走合同系统(spec-pay.enabled=false)时不拦截
if (hcsProperties.getSpecPay().isEnabled() && entity.getHcsSpecStatus() != null && List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PUSHING,
HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(entity.getHcsSpecStatus())) {
throw new ServiceException("收款方客商为高风险/黑名单,付款特批审核中(" + entity.getCurrentNode() + "),特批完成后方可推送金蝶");
}
@@ -667,10 +730,11 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|| KingdeeBfiConstant.KINGDEE_STATUS_PAYING.equals(entity.getKingdeeStatus())
|| KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(entity.getKingdeeStatus())
|| KingdeeBfiConstant.KINGDEE_STATUS_CLOSED.equals(entity.getKingdeeStatus())) {
// 存量数据金蝶单据id可能仍存于 kingdeeBillNo,兜底返回
// 优先返回金蝶单据号供提示文案展示;存量数据金蝶单据id可能仍存于 kingdeeBillNo,编号缺失时兜底返回单据id
String existBillId = Func.isNotEmpty(entity.getKingdeeBillId()) ? entity.getKingdeeBillId() : entity.getKingdeeBillNo();
log.info("付款申请[{}]金蝶单据已生成(状态[{}]),跳过重复推送,返回单据id={}", entity.getPaymentNo(), entity.getKingdeeStatus(), existBillId);
return existBillId;
String existBillNo = Func.isBlank(entity.getKingdeeBillNo()) ? existBillId : entity.getKingdeeBillNo();
log.info("付款申请[{}]金蝶单据已生成(状态[{}]),跳过重复推送,返回单据号={}", entity.getPaymentNo(), entity.getKingdeeStatus(), existBillNo);
return existBillNo;
}
log.info("付款申请[{}]开始同步金蝶:paymentType={},申请金额={},kingdeeStatus={}",
entity.getPaymentNo(), entity.getPaymentType(), entity.getAppliedAmount(), entity.getKingdeeStatus());
@@ -731,7 +795,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
updateById(entity);
log.info("付款申请[{}]同步金蝶完成:kingdeeStatus={},kingdeeBillId={},kingdeeBillNo={}",
entity.getPaymentNo(), entity.getKingdeeStatus(), kingdeeBillId, entity.getKingdeeBillNo());
return kingdeeBillId;
// 返回金蝶单据号供审批提示/手动同步文案展示,金蝶未返回编号时回退星瀚单据id
return Func.isBlank(entity.getKingdeeBillNo()) ? kingdeeBillId : entity.getKingdeeBillNo();
}
@Override
@@ -890,20 +955,28 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
/**
* 金蝶付款结果回写主体:A103-2 付款申请单查询回填 + A103-4 付款处理查询补付款记录,
* 由 {@link #syncKingdeePaymentResult()} 在分布式锁保护下调用
* 金蝶付款结果回写主体,由 {@link #syncKingdeePaymentResult()} 在分布式锁保护下调用,执行流程:
* <pre>
* ① 圈定待回写付款申请:审批通过且金蝶状态为 已生成/付款中/生成失败(终态已付款/已关闭不再轮询),
* 生成失败单据一并纳入:金蝶侧已生成说明上次推送实际成功(如下推超时但远端已落单、本地事务回滚),
* 查得即回填纠正为已生成,避免人工重推产生重复单据,未查得则保持生成失败等待人工重推;
* ② A103-2 付款申请单查询回填:按 paymentNo(awrp_src_code)分批查询,同一来源单号取最近修改一条,
* 回填金蝶单号/内码/同步状态/已付金额并联动预结算与正式结算付款记录;连续两轮查询未命中按金蝶反审核删除解锁;
* ③ A103-4 付款处理查询补付款记录:付款中/已付款单据 + 已付款无付款记录单据(补偿),
* 按付款组织 + awrp_sourcebillno(金蝶单据号)数组精确查询,双键勾稽后回填付款记录;
* ④ 汇总执行摘要:各计数随流程累计,单笔失败仅计数不中断整轮
* </pre>
*
* @return 执行摘要
*/
private String executePaymentResultSync() {
// ① 待回写付款申请:审批通过且金蝶状态为 已生成/付款中/生成失败(终态已付款/已关闭不再轮询)。
// 生成失败单据一并纳入勾稽:金蝶侧已生成说明上次推送实际成功(如下推请求超时但远端已落单、本地事务回滚),
// 查到即回填纠正为已生成,避免人工重新推送产生重复单据;未查到则保持生成失败等待异常处理后重新推送
// ① 待回写付款申请:审批通过且金蝶状态为 已生成/付款中/生成失败(终态已付款/已关闭不再轮询)
List<PaymentApplication> applications = list(Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
.in(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED,
KingdeeBfiConstant.KINGDEE_STATUS_PAYING, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)
.orderByAsc(PaymentApplication::getId));
// 自代理:applyPayApplyResult/applyPayBillRecord 带单据级事务,跨方法调用确保注解生效,单笔失败仅回滚自身
IPaymentApplicationService self = SpringUtil.getBean(IPaymentApplicationService.class);
int updated = 0;
int failed = 0;
@@ -913,19 +986,22 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
// ② A103-2 付款申请单查询:按 paymentNo(awrp_src_code)分批查询,同一来源单号取最近修改的一条
List<String> paymentNos = applications.stream()
.map(PaymentApplication::getPaymentNo).filter(Func::isNotEmpty).distinct().toList();
batchForeach(paymentNos, PAY_RESULT_QUERY_BATCH, batch ->
payApplyMap.putAll(latestPayApplyBySrcCode(queryPayApplyPages(batch))));
this.batchForeach(paymentNos, PAY_RESULT_QUERY_BATCH, batch ->
payApplyMap.putAll(this.latestPayApplyBySrcCode(this.queryPayApplyPages(batch))));
// 逐单回写:查得即回填并清零消失计数;未查得累计消失轮次(批次其余单据正常返回,排除服务整体故障)
for (PaymentApplication application : applications) {
BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo());
if (payApply == null) {
// 金蝶侧未查得已推送单据(查询批次其余单据正常返回,排除服务整体故障):
// 累计消失轮次,连续两轮视为金蝶已反审核删除,解锁置失败交人工核实重推
this.handlePayApplyMissed(application);
continue;
}
clearPayApplyMissKey(application.getId());
this.clearPayApplyMissKey(application.getId());
try {
if (self.applyPayApplyResult(application, payApply)) paidCount++;
// 返回 true 表示付款完成(终态),预结算/正式结算联动由 applyPayApplyResult 内部处理
if (self.applyPayApplyResult(application, payApply)) {
paidCount++;
}
updated++;
} catch (Exception exception) {
failed++;
@@ -933,7 +1009,9 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
}
}
// ③ A103-4 付款处理查询:付款中/已付款单据 + 已付款但尚无付款记录的单据(补偿),回填付款记录
// ③ A103-4 付款处理查询补付款记录:
// 候选一:本轮 A103-2 查得付款中/已付款(paystatus != Unpaid)的轮询单据;
// 候选二:已付款终态但尚无真实付款记录的单据(上轮付款记录回填失败的补偿)
Map<Long, PaymentApplication> recordCandidates = new LinkedHashMap<>();
for (PaymentApplication application : applications) {
BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo());
@@ -944,18 +1022,19 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
list(Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
.eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID))
.stream().filter(application -> realRecordCount(application.getId()) == 0)
.stream().filter(application -> this.realRecordCount(application.getId()) == 0)
.forEach(application -> recordCandidates.putIfAbsent(application.getId(), application));
int recordWritten = 0;
int recordFailed = 0;
if (!recordCandidates.isEmpty()) {
// 按付款组织分组(org_number 接口必传):优先取 A103-2 返回的付款组织编码,
// 兜底按合同关联项目解析(已付款补偿候选不在本轮 A103-2 返回内,须走兜底)
Map<String, List<PaymentApplication>> orgGroups = new LinkedHashMap<>();
for (PaymentApplication application : recordCandidates.values()) {
BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo());
String orgNumber = payApply == null ? null : payApply.getPayorgNumber();
if (Func.isBlank(orgNumber)) {
try {
// A103-4 付款人(公司)编码必填:优先取 A103-2 返回付款组织编码,兜底按合同关联项目解析
KingdeeSyncContext context = KingdeeSyncContext.empty();
orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(
kingdeeApBillSyncSupport.requireContract(application.getContractId(), context), context);
@@ -966,26 +1045,25 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
orgGroups.computeIfAbsent(orgNumber, key -> new ArrayList<>()).add(application);
}
// 以付款处理单源单id/源单编号建立索引,与本地金蝶单据id/单据编号勾稽
Map<String, BfiPayBillVO> payBillBySourceId = new HashMap<>();
Map<String, BfiPayBillVO> payBillBySourceNumber = new HashMap<>();
for (String orgNumber : orgGroups.keySet()) {
for (BfiPayBillVO payBill : queryPayBillPages(orgNumber)) {
if (Func.isEmpty(payBill.getBillno())) continue;
if (payBill.getSourcebillid() != null) {
payBillBySourceId.put(String.valueOf(payBill.getSourcebillid()), payBill);
}
if (Func.isNotEmpty(payBill.getSourcebillnumber())) {
payBillBySourceNumber.put(payBill.getSourcebillnumber(), payBill);
}
}
// 按组织 + 金蝶单据号(awrp_sourcebillno 支持数组)分批精确查询付款处理单,结果汇总后统一勾稽
List<BfiPayBillVO> payBills = new ArrayList<>();
for (Map.Entry<String, List<PaymentApplication>> orgEntry : orgGroups.entrySet()) {
List<String> kingdeeBillNos = orgEntry.getValue().stream()
.map(PaymentApplication::getKingdeeBillNo).filter(Func::isNotEmpty).distinct().toList();
this.batchForeach(kingdeeBillNos, PAY_RESULT_QUERY_BATCH,
batch -> payBills.addAll(this.queryPayBillPages(orgEntry.getKey(), batch)));
}
// 逐单勾稽回填:双键匹配(源单id=付款申请金蝶单据内码 / 源单编号=金蝶单据号),未匹配保持现状等下一轮
for (PaymentApplication application : recordCandidates.values()) {
BfiPayBillVO payBill = matchPayBill(application, payBillBySourceId, payBillBySourceNumber);
if (payBill == null) {
if (Func.isBlank(application.getKingdeeBillNo())) {
log.warn("付款申请[{}]付款处理查询跳过,金蝶单据编号为空", application.getPaymentNo());
continue;
}
try {
BfiPayBillVO payBill = this.matchPayBill(application, payBills);
if (payBill == null) {
continue;
}
self.applyPayBillRecord(application, payBill);
recordWritten++;
} catch (Exception exception) {
@@ -1010,7 +1088,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
if (payApply.getId() != null) {
entity.setKingdeeBillId(String.valueOf(payApply.getId()));
}
String kingdeeStatus = kingdeeResultStatus(payApply);
String kingdeeStatus = this.kingdeeResultStatus(payApply);
entity.setKingdeeStatus(kingdeeStatus);
// 已付金额:A103-2 分录 e_paidamt 汇总;分录缺失且已付款时兜底取申请金额
BigDecimal previousPaid = entity.getPaidAmount();
@@ -1020,7 +1098,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
.reduce(BigDecimal.ZERO, BigDecimal::add);
entity.setPaidAmount(paidAmount.setScale(2, RoundingMode.HALF_UP));
} else if (KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(kingdeeStatus)) {
entity.setPaidAmount(money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP));
entity.setPaidAmount(this.money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP));
}
// HCS 履约付款记录推进:金蝶实际付款推进(首次付款或金额变化)后置待同步,扫描任务凭
// hcs_perf_fd_id 判定走向——未建过履约记录走 C108-1 新建,已建走 C108-2 更新实际金额
@@ -1033,14 +1111,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
updateById(entity);
if (KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(kingdeeStatus)) {
// 付款完成:联动履约付款数据(预结算预付记录、正式结算付款记录)
syncPreSettlementAdvance(entity, "paid", money(entity.getAppliedAmount()));
syncFormalSettlementPayments(entity);
this.syncPreSettlementAdvance(entity, "paid", this.money(entity.getAppliedAmount()));
this.syncFormalSettlementPayments(entity);
return true;
}
// 生成失败单据勾稽确认金蝶已生成(已生成/付款中):与推送成功同语义,提前落预付记录回显预付信息
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(kingdeeStatus)
|| KingdeeBfiConstant.KINGDEE_STATUS_PAYING.equals(kingdeeStatus)) {
syncPreSettlementAdvance(entity, TransportBusiConstant.APPROVED, money(entity.getPaidAmount()));
this.syncPreSettlementAdvance(entity, TransportBusiConstant.APPROVED, this.money(entity.getPaidAmount()));
}
return false;
}
@@ -1066,7 +1144,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
if (payBill.getActpayamt() != null) {
record.setPaidAmount(payBill.getActpayamt().setScale(2, RoundingMode.HALF_UP));
}
record.setPaidDate(parseKingdeeDate(payBill.getPaydate()));
record.setPaidDate(this.parseKingdeeDate(payBill.getPaydate()));
record.setKingdeeBillNo(billNo);
// 付款处理单查询结果整体留档(凭证号、银行流水、电子回单等信息),便于追溯
record.setVoucherJson(JsonUtil.toJson(payBill));
@@ -1137,7 +1215,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
continue;
}
BfiPayApplyVO existing = latestBySrcCode.get(payApply.getAwrpSrcCode());
if (existing == null || strCompare(payApply.getModifytime(), existing.getModifytime()) > 0) {
if (existing == null || this.strCompare(payApply.getModifytime(), existing.getModifytime()) > 0) {
latestBySrcCode.put(payApply.getAwrpSrcCode(), payApply);
}
}
@@ -1145,7 +1223,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
private BfiPayApplyVO queryLatestPayApply(String paymentNo) {
return latestPayApplyBySrcCode(queryPayApplyPages(List.of(paymentNo))).get(paymentNo);
return this.latestPayApplyBySrcCode(this.queryPayApplyPages(List.of(paymentNo))).get(paymentNo);
}
/**
@@ -1157,7 +1235,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
* @return 星瀚付款申请单id,金蝶未生成返回 null
*/
private String reconcileExistPayApply(PaymentApplication entity) {
BfiPayApplyVO existPayApply = queryLatestPayApply(entity.getPaymentNo());
BfiPayApplyVO existPayApply = this.queryLatestPayApply(entity.getPaymentNo());
if (existPayApply == null || existPayApply.getId() == null) {
return null;
}
@@ -1185,21 +1263,27 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
/**
* A103-4 分页查询:按付款人(公司)编码 + 业务日期窗口,自动翻页
* A103-4 分页查询:按付款组织 + 外部来源单据编号(付款申请金蝶单据号)数组精确查询,自动翻页。
* <p>
* 窗口天数取 Nacos thirdParty.bfi.payResult.queryWindowDays(默认 60)
* 业务日期窗口与付款组织为接口必填参数,窗口天数取 Nacos thirdParty.bfi.payResult.queryWindowDays
* (默认 60),仅用于满足必填要求;awrp_sourcebillno 精确过滤后仅返回目标单据的付款处理单,
* 不再拉取组织内全量数据做内存勾稽
*
* @param orgNumber 付款人(公司)编码
* @param orgNumber 付款人(公司)编码,接口必传
* @param awrpSourceBillnos 付款申请金蝶单据号批次
* @return 付款处理单列表
*/
private List<BfiPayBillVO> queryPayBillPages(String orgNumber) {
private List<BfiPayBillVO> queryPayBillPages(String orgNumber, List<String> awrpSourceBillnos) {
LocalDate endDate = LocalDate.now();
int windowDays = Math.max(bfiProperties.getPayResult().getQueryWindowDays(), 1);
LocalDate beginDate = endDate.minusDays(windowDays);
BfiPayBillQueryDTO param = new BfiPayBillQueryDTO();
// 接口必填参数:付款组织编码 + 业务日期窗口(beigin_bizdate/end_bizdate,金蝶侧拼写如此)
param.setOrgNumber(List.of(orgNumber));
param.setBeginBizdate(beginDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
param.setEndBizdate(endDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
// 精确过滤参数:待回填单据的金蝶单据号数组(sourcebillnumber 与之同源)
param.setAwrpSourceBillno(awrpSourceBillnos);
return BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(PAY_RESULT_PAGE_SIZE);
@@ -1208,21 +1292,24 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
/**
* 以金蝶付款处理单源单id(=付款申请单星瀚id)/源单编号(=付款申请单单据编号)勾稽匹配
* 以金蝶付款处理单源单id(=付款申请单星瀚id)/源单编号(=付款申请单单据编号)勾稽匹配,
* 防止按单号查询返回非本单数据(如金蝶侧查询异常返回他单)
*
* @param application 付款申请实体
* @param payBillBySourceId 源单id索引
* @param payBillBySourceNumber 源单编号索引
* @param application 付款申请实体
* @param payBills 按本单金蝶单据号查询返回的付款处理单列表
* @return 匹配的付款处理单,未匹配返回 null
*/
private BfiPayBillVO matchPayBill(PaymentApplication application, Map<String, BfiPayBillVO> payBillBySourceId,
Map<String, BfiPayBillVO> payBillBySourceNumber) {
if (Func.isNotEmpty(application.getKingdeeBillId())
&& payBillBySourceId.containsKey(application.getKingdeeBillId())) {
return payBillBySourceId.get(application.getKingdeeBillId());
}
if (Func.isNotEmpty(application.getKingdeeBillNo())) {
return payBillBySourceNumber.get(application.getKingdeeBillNo());
private BfiPayBillVO matchPayBill(PaymentApplication application, List<BfiPayBillVO> payBills) {
for (BfiPayBillVO payBill : payBills) {
if (payBill.getSourcebillid() != null
&& Func.isNotEmpty(application.getKingdeeBillId())
&& String.valueOf(payBill.getSourcebillid()).equals(application.getKingdeeBillId())) {
return payBill;
}
if (Func.isNotEmpty(payBill.getSourcebillnumber())
&& payBill.getSourcebillnumber().equals(application.getKingdeeBillNo())) {
return payBill;
}
}
return null;
}
@@ -104,6 +104,33 @@ public class KingdeeApBillSyncSupport {
public static final String FEE_CATEGORY_FREIGHT = "A01";
/** 费用大类编码(业务字典 fee_category 键值):装卸 */
public static final String FEE_CATEGORY_LOADING = "A10";
/** 费用大类编码(业务字典 fee_category 键值):仓储 */
public static final String FEE_CATEGORY_WAREHOUSE = "A05";
/**
* 分录费用大类(三分类):label 用于分组键与分录标识拼接
*/
public enum FeeCategory {
/** 运输 */
FREIGHT("运输费"),
/** 仓储 */
WAREHOUSE("仓储"),
/** 装卸 */
LOADING("装卸");
private final String label;
FeeCategory(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
private final IBfiService bfiService;
private final ContractManageMapper contractManageMapper;
@@ -418,10 +445,10 @@ public class KingdeeApBillSyncSupport {
* 解析单张正式结算单分录:页面结算明细每行(一个运单)按 货物 → 费用大类 → 税率 拆分多行
* + 手动添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送)。
* <p>取数口径:
* 结算明细拆行——一车多货(存在货物费用行)先按货物拆;费用项名称含“装卸”归装卸大类、其余归运输大类,
* 各大类内按合同计费方案规则税率分组、不同税率分行,运输类每行数量取运单运输量、装卸类每行数量默认 1,
* 结算明细拆行——一车多货(存在货物费用行)先按货物拆;费用项按档案分类判定 装卸(A10)/仓储(A05)/运输 三大类,
* 各大类内按合同计费方案规则税率分组、不同税率分行,运输类每行数量取运单运输量、装卸/仓储类每行数量默认 1,
* 费用项金额合计与结算金额的残差并入运输类无税率组保证勾稽;
* 物料编码按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置);
* 物料编码按费用大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置);
* 单价按结算金额/数量反算;
* 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额;
* 运输货物/车号仅结算明细拆分行传(货物名称/车号);分录标识:结算明细拆分行取单据号-货物序号-大类-税率、
@@ -494,8 +521,8 @@ public class KingdeeApBillSyncSupport {
/**
* 单个货物费用行按费用大类/税率拆分生成分录:
* 费用项名称含“装卸”归装卸大类、其余归运输大类,各大类内按合同计费方案税率分组、不同税率分行;
* 运输类每行数量取运单运输量、装卸类每行数量默认 1;
* 费用项按档案分类判定 装卸(A10)/仓储(A05)/运输 三大类,各大类内按合同计费方案税率分组、不同税率分行;
* 运输类每行数量取运单运输量、装卸/仓储类每行数量默认 1;
* 运输类首行携带行不含税金额(拆分行无分类不含税金额,仅保证不含税合计勾稽);
* 运输货物取货物名称、车号取结算明细车号(同一车牌号就是一个车)
*/
@@ -519,21 +546,20 @@ public class KingdeeApBillSyncSupport {
String entryCodePrefix = documentNo + "-" + cargoSeq;
boolean amountNoTaxSent = false;
for (FeeLineSplit split : splits) {
String category = split.isLoading() ? "装卸" : "运输费";
// 物料:装卸大类映射装卸物料;运输类按项目业务模式/运单运输方式映射
String materialNumber = resolveMaterialNumber(split.isLoading(), context.project(), detail.getTransportType(), sourceDesc);
// 数量:运输类取运输量,装卸类默认 1
BigDecimal quantity = split.isLoading() ? BigDecimal.ONE : freightQuantity;
// 物料:装卸大类映射装卸物料、仓储大类映射仓储物料;运输类按项目业务模式/运单运输方式映射
String materialNumber = resolveMaterialNumber(split.getCategory(), context.project(), detail.getTransportType(), sourceDesc);
// 数量:运输类取运输量,装卸/仓储类默认 1
BigDecimal quantity = split.getCategory() == FeeCategory.FREIGHT ? freightQuantity : BigDecimal.ONE;
BigDecimal amount = money(split.getAmount());
BigDecimal price = entryPrice(amount, quantity);
// 不含税金额:仅运输类首行携带,保证不含税合计与结算明细勾稽
BigDecimal amountNoTax = null;
if (!split.isLoading() && !amountNoTaxSent && rowAmountNoTax != null) {
if (split.getCategory() == FeeCategory.FREIGHT && !amountNoTaxSent && rowAmountNoTax != null) {
amountNoTax = money(rowAmountNoTax);
amountNoTaxSent = true;
}
// 分录唯一标识:单据号-货物序号-费用大类-税率(税率未配置不拼)
String entryCode = entryCodePrefix + "-" + category + split.taxRateSuffix();
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("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]",
@@ -547,8 +573,8 @@ public class KingdeeApBillSyncSupport {
}
/**
* 单条手动添加的结算合计费用项生成一条分录:非运费类(装卸/仓储等杂费)映射装卸物料,
* 运费类按项目业务模式/运输方式映射(运输方式取结算明细首行);
* 单条手动添加的结算合计费用项生成一条分录:运费类按项目业务模式/运输方式映射(运输方式取结算明细首行),
* 仓储大类(档案编码 A05)映射仓储物料,其余非运费类映射装卸物料;
* 非运费类数量默认 1,金额取合计行结算金额,运输货物/车号不传,分录标识取结算单号-费用类型-费用项拼接
*/
private void appendSummaryEntry(List<BfiEntryGroup> entryGroups, EntryResolveContext context,
@@ -557,9 +583,10 @@ public class KingdeeApBillSyncSupport {
String feeType = Func.toStr(summaryFee.getFeeType()).trim();
String feeItem = Func.toStr(summaryFee.getFeeItem()).trim();
String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]";
// 非运费类(费用项档案分类编码非 A01 且名称无运费字样)统一映射装卸物料
boolean loadingFee = !isFreightCategory(feeType, feeItem, context.feeCategoryByName());
String materialNumber = resolveMaterialNumber(loadingFee, context.project(), fallbackTransportType, sourceDesc);
// 物料:运费类按项目业务模式/运输方式映射,仓储大类(档案编码 A05)映射仓储物料,其余非运费类映射装卸物料
FeeCategory feeCategory = isFreightCategory(feeType, feeItem, context.feeCategoryByName()) ? FeeCategory.FREIGHT
: isWarehouseCategory(feeItem, context.feeCategoryByName()) ? FeeCategory.WAREHOUSE : FeeCategory.LOADING;
String materialNumber = resolveMaterialNumber(feeCategory, context.project(), fallbackTransportType, sourceDesc);
// 结算合计费用项行无运输量,数量默认 1
BigDecimal quantity = BigDecimal.ONE;
BigDecimal amount = money(summaryFee.getSettlementAmount());
@@ -575,20 +602,22 @@ public class KingdeeApBillSyncSupport {
/**
* 物料编码映射(Nacos 配置 thirdparty.bfi.material.*):
* 装卸大类(及手动添加的非运费杂费)映射装卸物料 loading;业务模式/项目业务类型含“国际”(国际运输、
* 装卸大类映射装卸物料 loading;仓储大类映射仓储物料 warehouse;业务模式/项目业务类型含“国际”(国际运输、
* 国际货物运输代理服务)映射国际货代服务 international;国内运输按运单运输方式查 domestic 映射
* (公路运输/铁路运输/水路运输/航空运输),运输方式未配置映射时直接拦截报错
*
* @param loadingFee 该行是否属于装卸大类(或非运费类杂费)
* @param feeCategory 该行费用大类(运输/仓储/装卸)
* @param project 结算单关联项目(业务类型/业务模式取数来源)
* @param transportType 运单运输方式(结算合计费用项行取结算明细首行运输方式)
* @param sourceDesc 来源行描述,用于异常提示
* @return 金蝶物料编码
*/
private String resolveMaterialNumber(boolean loadingFee, ProjectApply project, String transportType, String sourceDesc) {
private String resolveMaterialNumber(FeeCategory feeCategory, ProjectApply project, String transportType, String sourceDesc) {
BfiProperties.Material material = bfiProperties.getMaterial();
String materialNumber;
if (loadingFee) {
if (feeCategory == FeeCategory.WAREHOUSE) {
materialNumber = material.getWarehouse();
} else if (feeCategory == FeeCategory.LOADING) {
materialNumber = material.getLoading();
} else if (isInternationalBusiness(project)) {
materialNumber = material.getInternational();
@@ -605,9 +634,9 @@ public class KingdeeApBillSyncSupport {
if (Func.isBlank(materialNumber)) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "未在配置 thirdparty.bfi.material 中维护对应物料编码,请补充后重试");
}
// 物料映射判定依据(联调排查:装卸大类/业务模式/项目业务类型/运输方式 -> 物料)
log.info("金蝶物料映射:来源[{}],装卸大类[{}],业务模式[{}],项目业务类型[{}],运单运输方式[{}] -> 物料编码[{}]",
sourceDesc, loadingFee ? "是" : "否",
// 物料映射判定依据(联调排查: 费用大类/业务模式/项目业务类型/运输方式 -> 物料)
log.info("金蝶物料映射: 来源[{}], 费用大类[{}], 业务模式[{}], 项目业务类型[{}], 运单运输方式[{}] -> 物料编码[{}]",
sourceDesc, feeCategory.getLabel(),
project == null ? null : project.getBusinessMode(), project == null ? null : project.getBusinessType(),
transportType, materialNumber);
return materialNumber;
@@ -684,7 +713,7 @@ public class KingdeeApBillSyncSupport {
/**
* 费用项按费用大类/税率拆分(纯函数):
* 费用项经费用项档案判为装卸大类(费用大类编码 A10)、其余归运输大类;
* 费用项经费用项档案判为 装卸(A10)/仓储(A05)/运输 三大类(仓储不做名称关键字兜底);
* 各大类内按计费方案规则税率分组(未配置税率的费用项归入无税率组),
* 费用项金额合计与结算金额的残差并入运输类无税率组,保证拆分后各行金额合计与结算金额勾稽
*
@@ -706,11 +735,11 @@ public class KingdeeApBillSyncSupport {
if (amount.signum() == 0) {
continue;
}
boolean loading = isLoadingCategory(item.getKey(), feeCategoryByName);
FeeCategory category = resolveCategory(item.getKey(), feeCategoryByName);
BigDecimal taxRate = taxRateByRule == null ? null : taxRateByRule.get(item.getKey());
FeeLineSplit split = grouped.get(splitGroupKey(loading, taxRate));
FeeLineSplit split = grouped.get(splitGroupKey(category, taxRate));
if (split == null) {
grouped.put(splitGroupKey(loading, taxRate), new FeeLineSplit(loading, taxRate, amount, item.getKey()));
grouped.put(splitGroupKey(category, taxRate), new FeeLineSplit(category, taxRate, amount, item.getKey()));
} else {
split.addAmount(amount);
}
@@ -719,16 +748,37 @@ public class KingdeeApBillSyncSupport {
BigDecimal residual = (rowAmount == null ? BigDecimal.ZERO : rowAmount).subtract(grouped.values().stream()
.map(FeeLineSplit::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add));
if (residual.signum() != 0) {
FeeLineSplit freightDefault = grouped.computeIfAbsent(splitGroupKey(false, null),
key -> new FeeLineSplit(false, null, BigDecimal.ZERO, FREIGHT_FEE_ITEM));
FeeLineSplit freightDefault = grouped.computeIfAbsent(splitGroupKey(FeeCategory.FREIGHT, null),
key -> new FeeLineSplit(FeeCategory.FREIGHT, null, BigDecimal.ZERO, FREIGHT_FEE_ITEM));
freightDefault.addAmount(residual);
}
return new ArrayList<>(grouped.values());
}
/** 费用大类/税率分组键(装卸大类与运输大类、税率去重分组) */
private static String splitGroupKey(boolean loading, BigDecimal taxRate) {
return (loading ? "装卸" : "运输费") + "@" + (taxRate == null ? "" : taxRate.stripTrailingZeros().toPlainString());
/** 费用大类/税率分组键(运输/仓储/装卸大类、税率去重分组) */
private static String splitGroupKey(FeeCategory category, BigDecimal taxRate) {
return category.getLabel() + "@" + (taxRate == null ? "" : taxRate.stripTrailingZeros().toPlainString());
}
/**
* 分录费用大类判定(三分类):费用项档案分类编码 A10 归装卸大类(保留名称关键字兜底现状)、
* A05 归仓储大类(仓储仅认档案编码,不做名称关键字兜底,分类以档案维护为准),其余归运输大类
*/
static FeeCategory resolveCategory(String feeItemName, Map<String, String> feeCategoryByName) {
String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
if (FEE_CATEGORY_WAREHOUSE.equals(categoryCode)) {
return FeeCategory.WAREHOUSE;
}
return isLoadingCategory(feeItemName, feeCategoryByName) ? FeeCategory.LOADING : FeeCategory.FREIGHT;
}
/**
* 仓储大类判定(手动添加结算合计费用行物料映射用):费用项档案维护的费用大类编码为 A05 归仓储大类,
* 无名称关键字兜底,分类以档案维护为准
*/
static boolean isWarehouseCategory(String feeItemName, Map<String, String> feeCategoryByName) {
String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
return FEE_CATEGORY_WAREHOUSE.equals(categoryCode);
}
/** 装卸大类费用项判定(关键字兜底):费用项名称含“装卸” */
@@ -796,17 +846,17 @@ public class KingdeeApBillSyncSupport {
}
/**
* 费用大类/税率拆分行:装卸大类 loading(true=装卸)、税率 taxRate(百分数,未配置为 null)、
* 费用大类/税率拆分行:费用大类 category(运输/仓储/装卸)、税率 taxRate(百分数,未配置为 null)、
* 组内金额合计 amount、组内首个费用项 firstFeeItem
*/
public static final class FeeLineSplit {
private final boolean loading;
private final FeeCategory category;
private final BigDecimal taxRate;
private final String firstFeeItem;
private BigDecimal amount;
FeeLineSplit(boolean loading, BigDecimal taxRate, BigDecimal amount, String firstFeeItem) {
this.loading = loading;
FeeLineSplit(FeeCategory category, BigDecimal taxRate, BigDecimal amount, String firstFeeItem) {
this.category = category;
this.taxRate = taxRate;
this.amount = amount;
this.firstFeeItem = firstFeeItem;
@@ -816,8 +866,18 @@ public class KingdeeApBillSyncSupport {
amount = amount.add(value);
}
/** 装卸大类判定(兼容保留) */
public boolean isLoading() {
return loading;
return category == FeeCategory.LOADING;
}
/** 仓储大类判定 */
public boolean isWarehouse() {
return category == FeeCategory.WAREHOUSE;
}
public FeeCategory getCategory() {
return category;
}
public BigDecimal getTaxRate() {
@@ -866,7 +926,7 @@ public class KingdeeApBillSyncSupport {
/**
* 费用项档案分类:费用项名称 -> 费用大类编码(blade_fee_item.fee_category,业务字典 fee_category 键值,
* 运输 A01 / 装卸 A10;批量同步时全批共享一次加载结果),费用大类判定用;
* 运输 A01 / 装卸 A10 / 仓储 A05;批量同步时全批共享一次加载结果),费用大类判定用;
* 档案读取失败直接拦截
*/
public Map<String, String> feeCategoryByName(KingdeeSyncContext context) {
@@ -997,6 +1057,25 @@ public class KingdeeApBillSyncSupport {
return this.kingdeeOrgNumber(contract.getProjectId(), context);
}
/**
* 金蝶组织编码:指定部门沿部门树向上反查唯一平台公司(是否平台公司=是),取该平台公司的金蝶组织编码。
* 先票后款链路收票登记不挂合同/项目,结算组织由页面手选部门派生(A303-2 org_number)
*
* @param deptId 手选部门id(收票登记 deptId)
* @param context 金蝶同步上下文(缓存载体)
* @return 组织编码
*/
public String kingdeeOrgNumberByDept(Long deptId, KingdeeSyncContext context) {
if (deptId == null) {
throw new ServiceException("推送金蝶失败,收票登记未选择结算组织,无法确定金蝶组织编码");
}
Dept dept = SysCache.getDept(deptId);
if (dept == null) {
throw new ServiceException("推送金蝶失败,所选结算组织[" + deptId + "]不存在,无法确定金蝶组织编码");
}
return this.platformCompanyOrgNumber(dept, dept.getDeptName());
}
/**
* 金蝶组织编码:项目业务部门沿部门树向上反查唯一平台公司(是否平台公司=是),取该平台公司的金蝶组织编码;
* 项目未关联/未维护业务部门/业务部门未归属平台公司/平台公司未维护金蝶组织编码时拦截
@@ -1007,8 +1086,15 @@ public class KingdeeApBillSyncSupport {
*/
public String kingdeeOrgNumber(Long projectId, KingdeeSyncContext context) {
ProjectApply project = this.requireOrgProject(projectId, context);
// 沿部门树向上反查平台公司,上限 20 级防脏数据成环死循环
Dept dept = SysCache.getDept(project.getBusinessDeptId());
if (dept == null) {
throw new ServiceException("推送金蝶失败,项目[" + Func.toStr(project.getProjectName()) + "]业务部门不存在,无法确定金蝶组织编码");
}
return this.platformCompanyOrgNumber(dept, project.getBusinessDeptName());
}
/** 部门树向上反查平台公司金蝶组织编码(上限 20 级防脏数据成环死循环),未归属平台公司时拦截 */
private String platformCompanyOrgNumber(Dept dept, String startDeptName) {
for (int depth = 0; dept != null && depth < 20; depth++) {
if (Objects.equals(dept.getIsPlatformCompany(), 1)) {
if (Func.isBlank(dept.getKingdeeDeptCode())) {
@@ -1018,7 +1104,7 @@ public class KingdeeApBillSyncSupport {
}
dept = dept.getParentId() == null ? null : SysCache.getDept(dept.getParentId());
}
throw new ServiceException("推送金蝶失败,项目业务部门[" + Func.toStr(project.getBusinessDeptName()) + "]未归属平台公司,无法确定金蝶组织编码");
throw new ServiceException("推送金蝶失败,部门[" + Func.toStr(startDeptName) + "]未归属平台公司,无法确定金蝶组织编码");
}
/**
@@ -1157,7 +1243,7 @@ public class KingdeeApBillSyncSupport {
/**
* 财务应付单/付款申请分录:结算明细按 货物→费用大类→税率 拆分行 + 结算合计费用项(手动添加的每行一条)
*
* @param materialNumber 物料编码(按装卸大类/项目业务模式/运单运输方式映射,thirdparty.bfi.material.* 配置)
* @param materialNumber 物料编码(按费用大类/项目业务模式/运单运输方式映射,thirdparty.bfi.material.* 配置)
* @param entryCode 分录唯一标识(awrp_entry_code):结算明细拆分行取单据号-货物序号-大类-税率,结算合计费用项行取结算单号-费用类型-费用项
* @param quantity 该行分录数量(运输类取运输量,装卸类/其它默认 1)
* @param price 该行分录单价(结算金额/数量反算)
@@ -28,6 +28,7 @@ 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.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.thirdparty.bfi.constant.BfiConstant;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignItemDTO;
@@ -37,6 +38,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
@@ -49,10 +51,12 @@ import org.springblade.transport.service.KingdeeSyncContext;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
@@ -191,8 +195,11 @@ public class KingdeeReceivedInvoiceSyncSupport {
param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract, context));
// 【必填】结算币别:合同结算币种
param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract));
// 【必填】往来户.编码:合同乙方(开票供应商)客商编码
param.setReceivablessuppNumber(kingdeeApBillSyncSupport.bfiAsstactNumber(contract, context));
// 【必填】往来户.编码:发票销售方金蝶供应商代码(缺失实时查 A901-1,仍缺失报错阻断)
String supplierNumber = this.requireSupplierNumber(receipt, mirror);
param.setReceivablessuppNumber(supplierNumber);
// 销售方名称.编码(非必填):同往来户供应商代码
param.setAsstactNumber(supplierNumber);
// 【必填】汇率表.编码
param.setExratetableNumber(BfiConstant.EXRATE_TABLE_DEFAULT);
// 【必填】销方名称:镜像开票单位
@@ -214,8 +221,8 @@ public class KingdeeReceivedInvoiceSyncSupport {
// param.setRemark(receipt.getRemark());
// 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_RECEIVED_INVOICE_SRC_TYPE);
// 【必填】明细分录:与应付单 A102-2 同源同口径,结算明细行+结算合计费用项各一条(不聚合)
param.setEntry(buildEntries(entryGroups));
// 【必填】明细分录:逐行取发票明细行(镜像 items_json),不与结算明细同源
param.setEntry(this.buildEntries(receipt, mirror));
BfiBillSaveResultVO saveResult = bfiService.saveReceivedInvoice(param);
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶收票单");
@@ -224,6 +231,30 @@ public class KingdeeReceivedInvoiceSyncSupport {
savedItem.getId() == null ? null : String.valueOf(savedItem.getId()));
}
/**
* 往来户供应商编码:优先取登记上已回填的 supplierCode;缺失时按销方名称实时查一次 A901-1 并回填实体
* (调用方推送成功后随推送结果落库),仍缺失报错阻断
*
* @param receipt 收票登记
* @param mirror 票池镜像(销方名称=开票单位)
* @return 金蝶供应商编码
*/
private String requireSupplierNumber(InvoiceReceipt receipt, KingdeeInvoicePool mirror) {
String supplierCode = receipt.getSupplierCode();
if (Func.isBlank(supplierCode)) {
log.info("收票登记[{}]往来户供应商代码缺失,按销方[{}]实时查询 A901-1", receipt.getInvoiceNo(), mirror.getIssuerName());
supplierCode = kingdeeApBillSyncSupport.queryCustomerCode(mirror.getIssuerName());
if (Func.isNotBlank(supplierCode)) {
receipt.setSupplierCode(supplierCode);
}
}
if (Func.isBlank(supplierCode)) {
throw new ServiceException("推送金蝶收票单失败:发票销售方[" + Func.toStr(mirror.getIssuerName())
+ "]未建档金蝶供应商编码,请先维护客商档案后再推送");
}
return supplierCode;
}
/**
* A303-2 收票单推送结果
*
@@ -234,42 +265,121 @@ public class KingdeeReceivedInvoiceSyncSupport {
}
/**
* 分录组装与应付单 A102-2 明细同源同口径:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条,
* 物料/数量/单价/含税/不含税金额
* 取自 {@link KingdeeApBillSyncSupport.BfiEntryGroup}(与应付单分录同一解析结果),分录标识同键,
* 便于金蝶侧收票单明细与应付单明细逐行勾稽;数量无效时兜底 1;
* 票面明细行(items_json)不再作为分录取数来源,税收分类/税率/税额随应付口径暂不送(联调强校验时再补)
* 分录组装:逐行取发票明细行(镜像 items_json 反序列化为 {@link BfiInvoicePoolItemVO}),一条明细行一条分录:
* 价税合计=明细金额+税额(合计为 0 拦截);数量取 num(≤0/空/不可解析兜底 1);单价取 unitPrice(空按价税合计/数量反算);
* 物料编码取行商品编码 goodsCode(缺失拦截,去尾部 0);不含税金额取明细金额(空不送);
* 税收分类编码/税率表编码/税额不送;分录标识=发票号码_seq(重推幂等候选键)
*
* @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果)
* @param receipt 收票登记(发票号码,分录标识前缀)
* @param mirror 票池镜像(明细行 items_json)
* @return A303-2 明细分录
*/
private List<BfiReceivedInvoiceSaveEntryDTO> buildEntries(List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups) {
private List<BfiReceivedInvoiceSaveEntryDTO> buildEntries(InvoiceReceipt receipt, KingdeeInvoicePool mirror) {
if (Func.isBlank(mirror.getItemsJson())) {
throw new ServiceException("推送金蝶收票单失败:发票[" + receipt.getInvoiceNo() + "]票池镜像无明细行,请先按票号补查票池明细");
}
List<BfiInvoicePoolItemVO> items = this.parseMirrorItems(receipt.getInvoiceNo(), mirror.getItemsJson());
if (Func.isEmpty(items)) {
throw new ServiceException("推送金蝶收票单失败:发票[" + receipt.getInvoiceNo() + "]票池镜像明细行为空,请先按票号补查票池明细");
}
List<BfiReceivedInvoiceSaveEntryDTO> entries = new ArrayList<>();
for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) {
BfiReceivedInvoiceSaveEntryDTO entry = new BfiReceivedInvoiceSaveEntryDTO();
// 【必填】价税合计:该行含税金额(与应付单 e_pricetaxtotal 同源取数)
entry.setEPricetaxtotal(kingdeeApBillSyncSupport.money(entryGroup.amount()));
// 【必填】数量:该行运输数量,无吨位数据时兜底 1(与应付单口径一致)
entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
// 【必填】单价:该行运输单价(与应付单同源;单据头 isincludetax=true 录入价税合计,单价仅作参考)
entry.setPrice(entryGroup.price());
// 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置,与应付单同源)
entry.setMaterialNumber(entryGroup.materialNumber());
// 金额(不含税):该行结算金额(不含税)(与应付单 e_amount 同源取数;结算合计费用项行无不含税金额不传)
if (entryGroup.amountNoTax() != null) {
entry.setEAmount(kingdeeApBillSyncSupport.money(entryGroup.amountNoTax()));
int rowNo = 0;
for (BfiInvoicePoolItemVO item : items) {
rowNo++;
// 分录序号:优先取明细行 seq,缺失按行号兜底
String seq = Func.isBlank(item.getSeq()) ? String.valueOf(rowNo) : item.getSeq().trim();
BigDecimal detailAmount = this.parseMirrorAmount(receipt.getInvoiceNo(), seq, "明细金额", item.getDetailAmount());
BigDecimal taxAmount = this.parseMirrorAmount(receipt.getInvoiceNo(), seq, "税额", item.getTaxAmount());
// 【必填】价税合计:明细金额+税额(合计为 0 拦截)
BigDecimal priceTaxTotal = kingdeeApBillSyncSupport.money(detailAmount == null ? BigDecimal.ZERO : detailAmount)
.add(kingdeeApBillSyncSupport.money(taxAmount == null ? BigDecimal.ZERO : taxAmount));
if (priceTaxTotal.compareTo(BigDecimal.ZERO) == 0) {
throw new ServiceException("推送金蝶收票单失败:发票[" + receipt.getInvoiceNo() + "]分录[" + seq + "]价税合计为 0,无法推送");
}
// 【必填】外部系统来源分录标识(候选键):与应付单分录同键(结算明细行单据号/结算合计费用项拼接),便于逐行勾稽
entry.setAwrpEntryCode(entryGroup.entryCode());
if (Func.isBlank(item.getGoodsCode())) {
throw new ServiceException("推送金蝶收票单失败:发票[" + receipt.getInvoiceNo() + "]分录[" + seq + "]缺少行商品编码,无法确定物料编码");
}
BfiReceivedInvoiceSaveEntryDTO entry = new BfiReceivedInvoiceSaveEntryDTO();
entry.setEPricetaxtotal(priceTaxTotal);
// 【必填】数量:明细行数量(≤0/空/不可解析兜底 1)
entry.setQuantity(this.mirrorQuantity(item.getNum()));
// 【必填】单价:明细行单价(空按价税合计/数量反算;单据头 isincludetax=true,单价仅作参考)
entry.setPrice(this.mirrorPrice(item.getUnitPrice(), priceTaxTotal, entry.getQuantity()));
String goodsCode = this.stripTrailingZeros(item.getGoodsCode().trim());
// 【必填】物料编码.编码:行商品编码(金蝶档案不含末尾补零,去掉尾部 0)
// entry.setMaterialNumber(goodsCode);
// 税收分类编码.编码:数电票行商品编码即税收分类编码
entry.setETaxclassNumber(goodsCode);
// 金额(不含税):明细金额(空不送);税收分类编码/税率表编码/税额不送,税额仅参与价税合计
if (detailAmount != null) {
entry.setEAmount(kingdeeApBillSyncSupport.money(detailAmount));
}
// 【必填】外部系统来源分录标识(候选键):发票号码_seq,重推同键幂等
entry.setAwrpEntryCode(receipt.getInvoiceNo() + "_" + seq);
entries.add(entry);
}
if (entries.isEmpty()) {
throw new ServiceException("推送金蝶收票单失败:分摊结算单无结算明细与结算合计费用项,无法组装收票单明细");
}
return entries;
}
/** 商品编码去尾部 0:金蝶物料编码档案不含末尾补零(如 3010102020100000000→30101020201);全 0 兜底保留原值 */
private String stripTrailingZeros(String code) {
String stripped = code.replaceAll("0+$", "");
return stripped.isEmpty() ? code : stripped;
}
/** 镜像明细行反序列化:items_json 解析异常按脏数据拦截(A801-1 原样落库,理论上可解析) */
private List<BfiInvoicePoolItemVO> parseMirrorItems(String invoiceNo, String itemsJson) {
try {
return Arrays.asList(JsonUtil.parse(itemsJson, BfiInvoicePoolItemVO[].class));
} catch (RuntimeException exception) {
log.warn("收票登记[{}]票池镜像明细行反序列化失败:{}", invoiceNo, exception.getMessage());
throw new ServiceException("推送金蝶收票单失败:发票[" + invoiceNo + "]票池镜像明细行数据异常,请先按票号补查票池明细");
}
}
/** 明细行数量:num ≤0/空/不可解析兜底 1 */
private BigDecimal mirrorQuantity(String num) {
BigDecimal quantity = this.parseMirrorAmountQuietly(num);
if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ONE;
}
return quantity;
}
/** 明细行单价:unitPrice 空/不可解析按 价税合计/数量 反算;金蝶要求小数不超过 4 位,统一 4 位舍入 */
private BigDecimal mirrorPrice(String unitPrice, BigDecimal priceTaxTotal, BigDecimal quantity) {
BigDecimal price = this.parseMirrorAmountQuietly(unitPrice);
BigDecimal result = price != null && price.compareTo(BigDecimal.ZERO) > 0
? price
: priceTaxTotal.divide(quantity, 4, RoundingMode.HALF_UP);
return result.setScale(4, RoundingMode.HALF_UP);
}
/** 镜像明细数值解析:空返回 null,不可解析返回 null(数量/单价有兜底,金额空不送) */
private BigDecimal parseMirrorAmountQuietly(String value) {
if (Func.isBlank(value)) {
return null;
}
try {
return new BigDecimal(value.trim());
} catch (NumberFormatException exception) {
return null;
}
}
/** 镜像明细金额解析:空返回 null,不可解析按脏数据拦截(金额直接进分录,不能静默丢弃) */
private BigDecimal parseMirrorAmount(String invoiceNo, String seq, String fieldLabel, String value) {
if (Func.isBlank(value)) {
return null;
}
try {
return new BigDecimal(value.trim());
} catch (NumberFormatException exception) {
throw new ServiceException("推送金蝶收票单失败:发票[" + invoiceNo + "]分录[" + seq + "]" + fieldLabel + "[" + value.trim() + "]无法解析,请先按票号补查票池明细");
}
}
// ==================== A302-6 收票指定应付 ====================
/**
@@ -171,6 +171,13 @@ public class HcsImmediatePushSupport {
return hcsProperties.getPush().isImmediateEnabled();
}
/**
* 付款特批走合同合规开关(thirdParty.hcs.spec-pay.enabled),关闭时审批响应不再提示特批待发起/已发起
*/
public boolean isSpecPayEnabled() {
return hcsProperties.getSpecPay().isEnabled();
}
/**
* 项目立项"通过"按钮路径:在审批事务提交返回后由 Controller 同步调用,失败原因留在当前线程供其取走
*
@@ -86,8 +86,9 @@ public class HcsPaymentSyncSupport {
public String pushPayment(PaymentApplication payment) {
StringBuilder result = new StringBuilder();
// ① 结算付款特批:提交时高风险/黑名单(须特批附件)或额度超额(hcs_spec_status=need)且未发起时发起 C106-3;
// 发起后挂起等待特批结果,特批通过后不推履约数据(各业务环节推送),仅补推金蝶(驳回不产生履约数据)
if (HcsSyncConstant.SPEC_STATUS_NEED.equals(payment.getHcsSpecStatus())) {
// 发起后挂起等待特批结果,特批通过后不推履约数据(各业务环节推送),仅补推金蝶(驳回不产生履约数据);
// spec-pay.enabled=false(特批不走合同系统)时跳过发起,need 仅作附件要求留痕
if (HcsSyncConstant.SPEC_STATUS_NEED.equals(payment.getHcsSpecStatus()) && hcsProperties.getSpecPay().isEnabled()) {
String specResult = hcsSpecPaySyncSupport.pushSpecPay(payment);
// C106-4 更新路径不改状态,统一归一为特批审核中,避免下轮扫描重复发起
if (HcsSyncConstant.SPEC_STATUS_NEED.equals(payment.getHcsSpecStatus())) {
@@ -69,8 +69,9 @@ public class BfiFeignClientConfig {
}
@Bean
public Request.Options options() {
return new Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true);
public Request.Options options(BfiProperties properties) {
return new Request.Options(properties.getConnectTimeoutSeconds(), TimeUnit.SECONDS,
properties.getReadTimeoutSeconds(), TimeUnit.SECONDS, true);
}
@Bean
@@ -64,6 +64,14 @@ public class BfiProperties {
* 语言字串,默认 zh_CN
*/
private String language = "zh_CN";
/**
* Feign 连接超时秒数,默认 10 秒
*/
private long connectTimeoutSeconds = 10;
/**
* Feign 读超时秒数,默认 120 秒:金蝶保存类接口偶发超 60 秒,超时后远端可能已生成单据,重推会自动勾稽防重
*/
private long readTimeoutSeconds = 120;
/**
* 是否启用 mock 模式(开发调试用):开启后 IBfiClient 走本地桩目录(有桩用桩、无桩透传真实接口,见 BfiClientMock)
*/
@@ -220,10 +220,11 @@ public interface IBfiClient {
* @param accessToken 访问令牌,通过 getToken 获取
* @param pageNo 页码,从1开始
* @param pageSize 页数
* @param beginBizdate 开始业务日期
* @param endBizdate 结束业务日期
* @param beginBizdate 开始业务日期,必传
* @param endBizdate 结束业务日期,必传
* @param billno 单据编号
* @param orgNumber 付款人(公司).编码
* @param orgNumber 付款人(公司).编码,必传,支持多个
* @param awrpSourceBillno 外部来源单据编号(付款申请金蝶单据号),支持多个,精确过滤付款处理单
* @return 付款处理分页数据
*/
@GetMapping(PAY_BILL_QUERY_URL)
@@ -233,7 +234,8 @@ public interface IBfiClient {
@RequestParam("beigin_bizdate") String beginBizdate,
@RequestParam("end_bizdate") String endBizdate,
@RequestParam(value = "billno", required = false) List<String> billno,
@RequestParam("org_number") List<String> orgNumber);
@RequestParam("org_number") List<String> orgNumber,
@RequestParam(value = "awrp_sourcebillno", required = false) List<String> awrpSourceBillno);
/**
* A902-1 客户查询
@@ -64,7 +64,11 @@ public class BfiPayBillQueryDTO implements Serializable {
*/
private List<String> billno;
/**
* 付款人(公司).编码
* 付款人(公司).编码,必传,支持多个
*/
private List<String> orgNumber;
/**
* 外部来源单据编号(付款申请金蝶单据号),支持多个,精确过滤付款处理单
*/
private List<String> awrpSourceBillno;
}
@@ -294,6 +294,7 @@ public class BfiServiceImpl implements IBfiService {
if (param == null || param.getPageNo() == null || param.getPageSize() == null) {
throw new BfiException("查询BFI付款处理失败,分页参数不能为空");
}
// 接口必填参数:业务日期范围 + 付款组织编码;awrp_sourcebillno 可选,精确过滤付款处理单
if (StringUtil.isBlank(param.getBeginBizdate()) || StringUtil.isBlank(param.getEndBizdate())) {
throw new BfiException("查询BFI付款处理失败,业务日期范围不能为空");
}
@@ -302,7 +303,8 @@ public class BfiServiceImpl implements IBfiService {
}
String accessToken = getAccessToken();
BfiResultVO<BfiPageVO<BfiPayBillVO>> result = commonCall("付款处理查询", () -> client.payBillQuery(accessToken,
param.getPageNo(), param.getPageSize(), param.getBeginBizdate(), param.getEndBizdate(), param.getBillno(), param.getOrgNumber()));
param.getPageNo(), param.getPageSize(), param.getBeginBizdate(), param.getEndBizdate(), param.getBillno(),
param.getOrgNumber(), param.getAwrpSourceBillno()));
return result.getData();
}
@@ -273,6 +273,13 @@ public class HcsProperties {
@Data
public static class SpecPay {
/**
* 付款特批走合同合规开关(thirdParty.hcs.spec-pay.enabled,默认 false 不推送合同合规):
* true 时审批通过后发起 C106-3/4、挂起"特批审核"、特批完成前拦截金蝶推送;
* false 时不再发起 C106-3/4、不挂起、不拦截金蝶推送;
* 提交时高风险/黑名单风险校验与「特批附件」必传逻辑保留(risk.check-on-payment)
*/
private boolean enabled = false;
/**
* 特批单据状态(fd_doc_status,示例 10,§9-U3 待联调)
*/
@@ -1,11 +1,18 @@
# HCS 合同管理操作手册
> **文档日期**:2026-10-23
> **文档日期**:2026-10-23(最近更新:2026-11-01)
> **适用系统**:运输板块数字化管理平台(TMS)
> **适用对象**:合同经办人、合同负责人、合同审批人、合规管理员
> **文档目的**:介绍合同新增、审批、变更、附件归档及 HCS 合规同步的完整操作方法
> **截图说明**:本文截图取自测试环境,合同编号、客商名称、金额等均为测试数据;不同环境因角色权限、功能开关不同,页面按钮和状态可能略有差异,以实际页面为准。
## 修改记录
| 日期 | 修改内容 |
| --- | --- |
| 2026-11-01 | ① 客商黑名单口径调整:黑名单以金蝶黑名单查询(A801-5)命中为准(含失信类别、列入事由),合同合规系统 C101-1 的黑名单状态不再用于展示与筛选;合同列表/详情"客商风险"改为实时关联客商档案组装,红色标签悬浮可查看甲乙双方风险明细(新增 16、17、18 截图)。② 新增客商风险每日定时刷新任务(每天 02:30,`risk.refresh-enabled` / `risk.refresh-cron`),刷新合同期限内生效合同关联客商并落库客商档案(新增 12.7 节)。③ 归档状态即时同步合规:上传"双章归档文件"后即时调 C103-11 更新合规归档状态(fdArchiveStatus=toarchive)并挂载归档附件;未推送合同首次推送报文自带归档状态(更新 9.4 节)。④ 付款链履约数据推送时点调整:金蝶实际付款门禁——未实付时履约计划(C103-9)与履约记录(C108)均不推送,金蝶付款回写后自动推送(同步更新纯操作版 3.11/4.5)。⑤ 合同推送报文新增业务合同号(fdBusContractNumber,=合同编号)与归档状态(fdArchiveStatus)字段;币种映射样例更新为 RMB/CNY→人民币、USD→美元。⑥ 新增 19 项目立项列表、20 付款申请列表截图;常见问题补充 3 条。 |
| 2026-10-23 | 首次整理。 |
---
## 1. 先了解合同管理与 HCS
@@ -29,6 +36,8 @@
| 合规合同ID | 合同成功推送 HCS 后,由 HCS 返回的合同唯一标识 |
| 金蝶合同ID | 合同在 HCS 完成合规及金蝶同步后,回写到 TMS 的金蝶合同编号 |
| 特批 | 高风险或黑名单客商合同,需经集团特批后才能完成合规流程 |
| 黑名单 | 客商被列入外部(国资委)黑名单,**以金蝶黑名单查询(A801-5)命中为准**,命中记录含失信类别与列入事由;金蝶分 A 类(禁止交易类)和 B 类(风险警示类) |
| 风险等级 | 合同合规系统(HCS)对客商的风险评级:0=低风险、1=中风险、2=高风险 |
| 临时合同 | 用于提前生效的合同,审批通过后有 **30 天** 临时效力 |
| 正式合同 | 完成正式审批、可长期履约的合同,是 HCS 合规同步与归档的主要对象 |
| 合同变更 | 正式合同内容发生变化时发起的变更审批流程 |
@@ -38,7 +47,7 @@
- 页面中带 `*` 的字段为必填项;保存草稿时可先填写部分信息,提交临时合同或正式合同前需补全。
- 合同编号可不填写,系统会按合同负责部门配置的规则自动生成;如业务要求手工编号,可直接填写。
- 临时合同审批通过后有效期为 **30 天**(自提交临时合同审批之日起算),到期前应及时转为正式合同。
- 高风险、黑名单客商合同,**提交审批前必须上传“特批附件”**,否则系统会阻止提交。
- 高风险、黑名单客商合同,**提交审批前必须上传“特批附件”**,否则系统会阻止提交。黑名单口径见 12.7 节。
- 合同审批通过后,系统会将合同推送 HCS;正式合同与合同变更是合规同步、归档的主要场景。是否自动推送由环境开关决定,也可在页面手动推送。
- 合同已推送 HCS 后,如需修改合同信息,必须走“变更”流程,**不要重复新增合同**,否则会在合规系统产生重复合同。
- 合同列表默认按创建时间倒序排列。
@@ -150,6 +159,7 @@ flowchart TD
| 合同阶段 | 草稿 / 临时合同 / 正式合同 / 已终止 |
| 审核状态 | 当前审批状态 |
| 归档状态 | 未归档 / 已归档 |
| 客商风险 | 甲方/乙方客商的风险等级与黑名单组合展示(如“低风险、黑名单”);红色标签鼠标悬浮可查看风险明细;数据实时关联客商档案组装 |
| 合规状态 | 待推送 / 已推送 / 推送失败;鼠标悬停可查看失败原因 |
| 金蝶合同ID | 合规同步完成后回写的金蝶合同编号 |
| 当前节点 / 当前处理人 | 当前审批节点及处理人 |
@@ -172,8 +182,7 @@ flowchart TD
| 临时合同 - 已驳回 | 查看、编辑、复制、重新提交转正 |
| 正式合同 - 审批中 | 查看、撤回、通过/驳回(审批人)、流程 |
| 正式合同 - 审批通过 / 变更审批通过 | 查看、复制、变更、附件上传、流程、推送合规(未同步时) |
| 正式合同 - 变更审批中 | 查看、通过/驳回(审批人)、撤回变更、流程 |
| 正式合同 - 变更驳回 | 查看、编辑变更内容、重新提交变更 |
| 正式合同 - 变更审批中 / 变更驳回 | 存量数据状态;现变更的审批、撤回、重新提交均在“合同变更”页面操作(见第 10 章) |
> 说明:
> - “复制”会以当前合同为模板打开新增页面,便于快速新建相似合同。
@@ -317,9 +326,9 @@ flowchart TD
| 审批通过 | 审批完成 |
| 已驳回 | 审批被驳回,需修改后重新提交 |
| 已撤回 | 提交人主动撤回,可修改后重新提交 |
| 变更审批中 | 合同变更审批中 |
| 变更审批中 | 合同变更审批中(现由“合同变更”单跟踪,存量数据仍可能显示) |
| 变更审批通过 | 合同变更审批完成 |
| 变更驳回 | 合同变更被驳回,可修改后重新提交变更 |
| 变更驳回 | 合同变更被驳回(现由“合同变更”单跟踪,存量数据仍可能显示) |
### 6.4 其他状态说明
@@ -384,10 +393,14 @@ flowchart TD
| --- | --- |
| 合规同步状态 | 待推送 / 已推送 / 推送失败 |
| 合规合同ID | HCS 返回的合同唯一标识 |
| 客商风险 | 是否黑名单、风险等级 |
| 客商风险 | 甲方/乙方客商风险等级与黑名单组合展示(如“高风险、黑名单”);黑色标签为命中风险,鼠标悬浮可查看明细(失信类别、列入事由、风险等级) |
| 特批状态 | 待特批 / 特批中 / 特批通过 / 特批驳回 |
| 特批说明 | 特批原因或处理说明 |
![合同列表客商风险悬浮明细](images/18-合同列表-客商风险悬浮.png)
> 合同列表“客商风险”列:红色标签为高风险/黑名单客商,鼠标悬浮展示甲乙双方风险明细(失信类别、列入事由、风险等级)。
### 8.3 用印材料与附件
向下滚动可查看用印材料、计费信息、结算单规则、付款比例设置、附件(佐证材料等)。
@@ -438,25 +451,28 @@ flowchart TD
- 上传附件类型为 **双章归档文件** 后,合同归档状态会在审批通过或附件保存后变为“已归档”。
- 双章归档文件属于用印材料,请在“附件位置”中选择“用印材料”。
- **归档状态即时同步合规**:上传双章归档文件保存后,若合同已推送合规(已有合规合同ID),系统会立即调 C103-11 更新合规侧归档状态为“待归档”(fdArchiveStatus=toarchive),并将归档附件挂载到合规合同的其他附件面板;若合同尚未推送合规,首次推送报文会自带归档状态。合规侧后续的归档流转(归档中/已归档/归档异常)由合规系统自行维护。
---
## 10. 合同变更
### 10.1 适用条件
### 10.1 适用条件与变更单
只有**正式合同**,且审核状态为“审批通过”“变更审批通过”或“变更驳回”时,才允许发起变更。
合同变更是**独立功能页面**(菜单:合同管理 → 合同变更),以“合同变更单”形式管理:每张变更单有独立编号、变更类型、审批状态和合规同步状态,与原合同分开审批。
### 10.2 发起变更
1. 在合同列表找到正式合同。
2. 点击操作列 **变更**(变更驳回后可点击“重新提交变更”)。
3. 进入“合同变更”页面。
2. 点击操作列 **变更**(变更驳回后可点击“重新提交变更”),系统创建一张合同变更单草稿并自动跳转“合同变更”页面。同一合同已有审批中的变更单时不允许再次发起;已有草稿、已撤回或变更驳回的变更单时,直接打开继续编辑。
![合同变更-基本信息](images/09-合同变更-基本信息.png)
> 合同变更页面
3. 选择 **变更类型**:合同信息变更 / 结算规则变更 / 终止合同。结算规则变更仅调整计费信息与结算单规则,不校验合同名称、结算币种等基本信息。
4. 修改需要变更的内容:基本信息、用印材料、计费信息、结算单规则、付款比例、附件等。
5. 页面会展示风险客商提示;如存在高风险/黑名单客商,变更提交前同样必须上传“特批附件”。
@@ -468,18 +484,21 @@ flowchart TD
1. 在“变更原因”中填写本次变更的原因,最多 500 字。
2. 在“变更材料”区域上传相关附件。
3. 点击页面底部 **提交**。
4. 在确认弹窗中点击“确定”。
3. 点击页面底部 **提交**(从“合同变更”菜单进入时可先 **保存草稿**)。
4. 在确认弹窗中点击“确定”,变更单变为“审批中”。
### 10.4 变更审批
- 提交后审核状态变为“变更审批中”。
- 审批人可执行“通过”“驳回”“撤回变更”。
- 审批通过后,审核状态变为“变更审批通过”,系统按合规合同ID更新 HCS;如该合同尚未推送过,则按首次推送处理。
- 审批驳回后,审核状态变为“变更驳回”,可点击“重新提交变更”重新填写变更原因后再次提交。
审批在“合同变更”列表页操作;接入 MK 审批的环境,提交后进入 MK 审批流。
- 审批人可执行“审批通过”“审批驳回”“撤回”。
- 审批通过后,变更单回写原合同:原合同状态变为“变更审批通过”,系统按合规合同ID更新 HCS;如该合同尚未推送过,则按首次推送处理。
- 审批驳回后,变更单状态变为“变更驳回”,可重新编辑后再次提交。
- 撤回仅限审批中的变更单,撤回后状态变为“已撤回”,可重新编辑后再次提交;撤回不影响原合同状态。
- 仅草稿、已撤回、变更驳回的变更单允许删除。
> 说明:
> - 变更审批通过后,变更附件会合并到合同附件中,变更原因、变更内容会被清空,变更记录保留。
> - 变更审批通过后,变更附件会合并到合同附件中,变更原因、变更内容会被清空,变更记录保留在合同详情页底部。
> - 页面中的“终止合同”选项当前版本暂未开放,请选择“合同信息变更”。
---
@@ -513,7 +532,7 @@ flowchart TD
| ① 客商风险校验 | 校验甲乙双方是否高风险/黑名单(C101-1) |
| ② 附件转传 | 将合同附件转传到合规系统(C103-2) |
| ③ 合同判重 | 按合同编号等信息判断合规系统是否已存在(C103-3) |
| ④ 起草 / 更新 | 首次推送起草合同(C103-1);已有合规合同ID时更新合同(C103-11) |
| ④ 起草 / 更新 | 首次推送起草合同(C103-1);已有合规合同ID时更新合同(C103-11)。报文含业务合同号(fdBusContractNumber,取值同合同编号)与归档状态(fdArchiveStatus,已上传双章归档文件时传“待归档”) |
| ⑤ 履约计划同步 | 同步合同履约计划/履约规则(C103-9),系统自动创建默认履约计划,无需人工维护 |
| ⑥ 特批 | 高风险/黑名单客商合同自动进入特批流程 |
@@ -569,8 +588,8 @@ flowchart TD
当合同甲方或乙方为外部客商,且命中以下任一情况时,需要走特批流程:
- 客商为黑名单;
- 客商风险等级为高风险。
- 客商为黑名单(以金蝶黑名单查询 A801-5 命中为准,见 12.7 节);
- 客商风险等级为高风险(合同合规系统 C101-1 评级)。
### 12.2 提交前:上传特批附件
@@ -629,6 +648,27 @@ flowchart TD
合同详情页右上角的 **发起特批** 是旧版独立发起入口。当前合规接口已改为随合同推送自动发起特批,正常流程**无需点击该按钮**;点击后如提示接口不存在,不影响合同数据。如特批状态长时间停留在“待特批”,请联系系统管理员重新推送合同。
### 12.7 客商风险数据与每日刷新
合同列表、详情和客商档案展示的“客商风险”来自两个外部系统,每日自动更新到客商档案:
| 数据 | 来源 | 说明 |
| --- | --- | --- |
| 风险等级 | 合同合规系统(C101-1) | 低风险 / 中风险 / 高风险 |
| 金蝶黑名单 | 金蝶(星瀚)· 国资委黑名单(A801-5) | 命中记录失信类别(A 类禁止交易类 / B 类风险警示类)与列入事由 |
- **刷新时机**:系统每天凌晨 02:30 自动刷新(合同期限内生效合同的甲乙方关联客商);当天新增的客商在客商档案表单里有实时风险提示,列表数据次日刷新后生效。
- **展示口径**:黑名单仅指金蝶黑名单命中;风险等级与黑名单组合展示(等级在前,如“高风险、黑名单”);内部组织客商不查询风险,默认“无风险”。
- **悬浮明细**:红色标签(黑名单/高风险)鼠标悬浮可查看明细,格式:`【客商名称】:被列入外部黑名单(失信类别:xx);列入事由 xx;风险等级:xx`。
![客商档案列表客商风险列](images/16-客商档案列表-客商风险.png)
> 客商档案列表:搜索栏“客商风险”下拉可筛选黑名单/高风险/中风险/低风险;红色标签为命中风险客商。
![客商风险悬浮明细](images/17-客商风险悬浮明细.png)
> 红色标签悬浮明细:失信类别、列入事由、风险等级。
---
## 13. 常见问题
@@ -681,6 +721,18 @@ A:说明该合同没有关联外部审批流程实例。请以列表/详情中
A:批量推送只允许“审批通过/变更审批通过”且未同步的合同。请取消勾选不符合条件的合同,或单独对符合条件的合同发起推送。
**Q13:客商档案/合同列表的“客商风险”列显示“-”是为什么?**
A:该客商不在任何合同期限内生效合同的关联范围内,尚未被每日刷新任务(每天 02:30)查询过;或客商档案刚创建、数据次日才落库。当天需要确认时,可在客商档案新增/编辑表单填写客商名称,系统会实时查询并显示风险提示。
**Q14:客商明明命中了金蝶黑名单,列表为什么只显示风险等级、没有“黑名单”?**
A:黑名单标签需要金蝶黑名单记录的失信类别/列入事由数据(每日刷新任务落库)。数据缺失时只显示风险等级;等待次日刷新或联系管理员执行刷新后恢复完整展示。
**Q15:付款特批通过后,为什么合规系统里迟迟没有履约计划和履约记录?**
A:付款链的履约数据以金蝶实际付款为门禁:付款审批通过只做本地计划金额累加,**金蝶完成实际付款并回写实付金额后**,系统才自动推送履约计划(C103-9)与履约记录(C108)。实付前属正常等待,不是推送失败。
---
## 14. 操作权限速查
@@ -692,12 +744,22 @@ A:批量推送只允许“审批通过/变更审批通过”且未同步的合
| 删除合同 | 草稿状态 |
| 复制合同 | 具有合同管理复制权限,复制后为新增草稿 |
| 转正式合同 | 临时合同 + 审批通过 |
| 变更 | 正式合同 + 审批通过 / 变更审批通过 |
| 重新提交变更 | 正式合同 + 变更驳回 |
| 变更 | 正式合同 + 审批通过 / 变更审批通过(创建变更单草稿并跳转“合同变更”页面) |
| 重新提交变更 | 正式合同 + 变更驳回(存量数据;现变更单重提在“合同变更”页面操作) |
| 附件上传 | 正式合同 + 审批通过 / 变更审批通过 |
| 推送合规 | 审批通过 / 变更审批通过 + 未同步 |
| 通过 / 驳回 | 审批中 / 变更审批中 + 审批权限 |
| 撤回 | 审批中(撤回变更仅限变更审批中) |
| 通过 / 驳回 | 审批中 / 变更审批中 + 审批权限(变更的审批现由“合同变更”单操作) |
| 撤回 | 审批中(变更单的撤回在“合同变更”页面操作) |
**“合同变更”页面(菜单:合同管理 → 合同变更):**
| 操作 | 所需条件 |
| --- | --- |
| 保存草稿 / 编辑 | 草稿、已撤回、变更驳回的变更单 |
| 提交(提交审批) | 草稿、已撤回、变更驳回的变更单 |
| 撤回 | 变更单审批中 |
| 审批通过 / 审批驳回 | 变更单审批中 + 审批权限 |
| 删除 | 草稿、已撤回、变更驳回的变更单 |
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

@@ -1,11 +1,18 @@
# HCS 合同管理操作手册(纯操作版)
> 整理日期:2026-10-23
> 整理日期:2026-10-23(最近更新:2026-11-01)
> 适用对象:合同经办人、合同负责人、合同审批人、合规管理员、实施运维。
> 本文只讲**怎么操作**:在哪个页面、点什么按钮、前提是什么、成功的标志是什么、失败了怎么处理。
> 第 1–3 节为业务操作(合同、项目立项、付款特批);第 4 节为实施运维使用的五类 HCS 定时任务逻辑、接口与配置。
> 第 1–3 节为业务操作(合同、项目立项、付款特批、客商风险);第 4 节为实施运维使用的定时任务逻辑、接口与配置。
> 完整字段说明、状态字典与流程背景见同目录《2026-10-23-HCS合同管理操作手册》;接口与代码细节见同目录 HCS 相关梳理文档。
## 修改记录
| 日期 | 修改内容 |
|---|---|
| 2026-11-01 | ① 客商黑名单口径调整:黑名单以金蝶黑名单查询(A801-5)命中为准(含失信类别、列入事由),C101-1 黑名单状态不再用于展示与筛选;合同列表/详情“客商风险”改为实时关联客商档案组装,红色标签悬浮查看甲乙双方明细(新增 1.6、3.12 节与截图 16/17/18)。② 新增客商风险每日定时刷新任务(02:30,`risk.refresh-enabled`/`refresh-cron`),第 4 章任务数 5→6。③ 归档状态即时同步合规:上传双章归档文件后即时调 C103-11 传 fdArchiveStatus=toarchive 并挂归档附件(更新 3.9)。④ 付款链履约数据改金蝶实际付款门禁:未实付不推履约计划 C103-9 与履约记录 C108,实付回写后自动推送(更新 3.11、4.5);付款链履约计划重推纳入 `exe-stage.sync-enabled` 控制。⑤ 合同推送报文新增业务合同号(=合同编号)与归档状态字段;币种映射样例更新。⑥ 常见问题补 3 条。 |
| 2026-10-23 | 首次整理。 |
---
合同系统
@@ -64,6 +71,7 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
- 付款申请收款方为高风险/黑名单客商时,提交前必须上传“特批附件”,否则提交被拦截。
- 付款申请审批通过后系统自动发起付款特批;特批中“当前节点”为“特批审核”,特批未通过前不推送履约数据、不同步金蝶。
- 付款特批驳回后付款申请变“已驳回”,修改后重新提交审批会重新发起特批。
- 上传“双章归档文件”保存后,已推送合规的合同会即时更新 HCS 归档状态(待归档)并挂载归档附件;未推送合同首次推送自带归档状态。
### 1.5 推送与环境前提
@@ -92,6 +100,20 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
2. `thirdParty.hcs.spec-pay.template-id` 已配置,付款用途/性质/类型字典值正确。
3. 收款方为高风险/黑名单时,已上传“特批附件”。
### 1.6 客商风险与黑名单口径
客商风险数据来自两个外部系统,每日自动刷新到客商档案(详见 3.12、4.8 节):
| 数据 | 来源 | 说明 |
|---|---|---|
| 风险等级 | 合同合规系统(C101-1) | 低风险 / 中风险 / 高风险 |
| 金蝶黑名单 | 金蝶(星瀚)· 国资委黑名单(A801-5) | 命中记录失信类别(A 类禁止交易类 / B 类风险警示类)与列入事由 |
- **黑名单仅指金蝶黑名单命中**;合同合规系统返回的黑名单状态字段只用于付款提交强制校验,不在列表展示。
- 合同列表/详情、客商档案的“客商风险”列为等级+黑名单组合展示(等级在前,如“高风险、黑名单”);红色标签悬浮可查看明细(失信类别、列入事由、风险等级)。
- 内部组织客商不查询风险,默认“无风险”。
- 数据每天 02:30 刷新(合同期限内生效合同关联客商);当天新增客商以表单实时提示为准,列表次日生效。
---
## 2. 一次性准备(实施/管理员操作)
@@ -102,7 +124,7 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
| 2 | 来源与操作人 | 运维 | `thirdParty.hcs.src-system`(约定 WLTMS)、`operator-login-name` | 合规侧来源/操作人不符合约定 |
| 3 | 合同类别模板映射 | 运维 | `thirdParty.hcs.contract.template-mapping`,为**每个合同类别**配置 HCS 模板ID | 未映射的类别推送必失败,提示“合同类别未配置合规模板ID” |
| 4 | 附件下载基址 | 运维 | `thirdParty.hcs.attachment.file-download-base`;保持 `transfer-enabled=true` | 合同有附件时转传失败 |
| 5 | 币种映射 | 运维 | `thirdParty.hcs.contract.currency-name-mapping`(如 RMB→人民币) | 币种传错或为空 |
| 5 | 币种映射 | 运维 | `thirdParty.hcs.contract.currency-name-mapping`(样例:RMB/CNY→人民币、USD→美元) | 币种传错或为空 |
| 6 | 风险校验开关 | 运维 | `thirdParty.hcs.contract.require-risk-check` 保持 `true` | 关闭后高风险合同不会触发特批 |
| 7 | 履约计划同步 | 运维 | `thirdParty.hcs.exe-stage.sync-enabled`,一期默认 `false` | 不推送 C103-9 履约计划,不影响合同主流程 |
| 8 | 推送开关 | 运维 | 按需开启 `push.immediate-enabled`(项目/合同即时)、`push.project/contract/payment.sync-enabled`(定时扫描) | 只能靠手动按钮推送;付款审批即时特批不受 `push.immediate-enabled` 控制 |
@@ -112,10 +134,11 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
| 12 | 付款特批模板与字典 | 运维 | `thirdParty.hcs.spec-pay.template-id`、`payment-purpose`、`payment-nature`、`payment-type` | 付款特批发起失败 |
| 13 | 付款履约记录 | 运维 | `thirdParty.hcs.perf-record.sync-enabled`(一期默认 `false`);开启时同时配置 `perf-record.xform-id` | 开启但未配模板ID时履约记录推送失败 |
| 14 | 付款额度校验 | 运维 | 如需“超额自动转特批”,开启 `thirdParty.hcs.credit.check-enabled` | 关闭时超额付款不会自动进入特批 |
| 15 | 客商风险每日刷新 | 运维 | `thirdParty.hcs.risk.refresh-enabled` 上线置 `true`(默认 `false`)、`refresh-cron` 默认每天 02:30 | 客商档案/合同列表“客商风险”列无数据或长期不更新 |
> 模板映射的中文键在 Nacos YAML 中必须使用括号写法,例如 `"[承运商合同]": "模板ID"`,否则配置无法正确绑定。
>
> 当前 Nacos 样例只映射了“承运商合同”。客户合同、补充协议如果也要推送,必须在上线前逐类确认并补齐映射。
> 当前 Nacos 样例已映射“承运商合同”“客户合同”。补充协议等其他类别如果也要推送,必须在上线前逐类确认并补齐映射。
---
@@ -242,10 +265,12 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
|---|---|
| 合规同步状态 | 待推送 / 已推送 / 推送失败;失败时可悬停查看原因 |
| 合规合同ID | HCS 返回的合同唯一标识,推送成功后回填 |
| 客商风险 | 是否黑名单、风险等级 |
| 客商风险 | 甲方/乙方客商等级+黑名单组合展示;红色标签悬浮查看明细(失信类别、列入事由、风险等级),口径见 1.6 |
| 特批状态 | 待特批 / 特批中 / 特批通过 / 特批驳回 |
| 特批说明 | 特批原因或处理说明 |
![合同列表客商风险列:红色标签悬浮展示甲乙双方风险明细(失信类别、列入事由、风险等级)](./2026-10-23-HCS合同管理操作手册(纯操作版).assets/18-合同列表-客商风险悬浮.png)
**推送成功的判断标准**:合规状态为“已推送”,且“合规合同ID”不为空。
> 金蝶合同ID由 HCS 完成金蝶同步后回写,可能存在延迟;暂时为空可稍后刷新列表查看。“已推送”只代表 HCS 已受理,不代表特批和金蝶同步已经完成。
@@ -326,7 +351,7 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
### 3.8 合同变更并更新 HCS
**页面:合同管理 → 合同管理 → 变更**
**入口:合同管理列表操作列 → 变更;变更单在独立页面“合同变更”中填写与审批(菜单:合同管理 → 合同变更)**
![合同变更页面:在正式合同基础上修改基本信息、附件、计费、结算和付款比例](./2026-10-23-HCS合同管理操作手册(纯操作版).assets/09-合同变更-基本信息.png)
@@ -334,23 +359,24 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
前提:合同阶段为“正式合同”,审核状态为“审批通过”“变更审批通过”或“变更驳回”。
1. 在列表点操作列 **变更**(变更驳回后点“重新提交变更”)。
2. 修改需要变更的内容:基本信息、用印材料、计费信息、结算单规则、付款比例、附件等。
3. 填写 **变更原因**,最多 500 字。
4. 在“变更材料”区域上传相关附件。
5. 点页面底部 **提交**,在确认弹窗点“确定”。
6. 审核状态变为 **变更审批中**。
1. 在合同管理列表点操作列 **变更**(变更驳回后点“重新提交变更”),系统创建一张合同变更单草稿并自动跳转到“合同变更”页面。同一合同已有审批中的变更单时不允许再次发起;已有草稿、已撤回或变更驳回的变更单时,直接打开继续编辑。
2. 选择 **变更类型**:合同信息变更 / 结算规则变更 / 终止合同。结算规则变更仅调整计费信息与结算单规则。
3. 修改需要变更的内容:基本信息、用印材料、计费信息、结算单规则、付款比例、附件等。
4. 填写 **变更原因**,最多 500 字。
5. 在“变更材料”区域上传相关附件。
6. 点页面底部 **提交**(从“合同变更”菜单进入时可先 **保存草稿**),在确认弹窗点“确定”,变更单变为 **审批中**。
**审批人操作:**
**审批人操作(在“合同变更”列表页):**
- 通过:审核状态变为 **变更审批通过**,系统按合规合同ID更新 HCS(C103-11);如该合同从未推送过,则按首次推送处理。
- 驳回:审核状态变为 **变更驳回**,可点“重新提交变更”重新填写并提交。
- 撤回变更:仅变更审批中可撤回,撤回后恢复为“审批通过”。
- 审批通过:变更单审批通过后回写原合同,原合同状态变为 **变更审批通过**,系统按合规合同ID更新 HCS(C103-11);如该合同从未推送过,则按首次推送处理。
- 审批驳回:变更单状态变为 **变更驳回**,可重新编辑后再次提交。
- 撤回:仅审批中可撤回,撤回后变更单状态变为 **已撤回**,可重新编辑后再次提交;撤回不影响原合同状态。
- 仅草稿、已撤回、变更驳回的变更单允许删除。
**变更结果:**
- 变更附件合并到合同附件,变更原因、变更内容被清空。
- 变更记录保留,可在详情页底部查看。
- 变更记录保留,可在合同详情页底部查看。
![合同详情变更记录:展示变更日期、经办人、变更类型、变更内容、变更原因和状态](./2026-10-23-HCS合同管理操作手册(纯操作版).assets/08-合同详情-变更记录.png)
@@ -382,6 +408,7 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
- 上传附件类型为 **双章归档文件**,且附件位置选择“用印材料”时,合同审批通过或附件保存后归档状态变为 **已归档**。
- 其他附件类型不会改变归档状态。
- **即时同步合规**:双章归档文件保存后,已推送合规的合同会立即调 C103-11 更新合规侧归档状态为“待归档”(fdArchiveStatus=toarchive)并挂载归档附件;尚未推送的合同在首次推送报文中自带归档状态。合规侧后续归档流转由合规系统自行维护。
![合同详情附件区:可查看、下载用印材料和佐证材料](./2026-10-23-HCS合同管理操作手册(纯操作版).assets/07-合同详情-附件佐证材料.png)
@@ -477,7 +504,7 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
| 提示 | 含义 | 后续 |
|---|---|---|
| 审批通过,已发起付款特批,特批通过后将自动推送履约数据并同步金蝶 | 特批已发起 | 列表“当前节点”显示“特批审核”,等待结果 |
| 审批通过,已发起付款特批,特批通过后将自动推送履约数据并同步金蝶 | 特批已发起 | 列表“当前节点”显示“特批审核”,等待结果;履约数据在金蝶实际付款后才推送(见“特批通过后”) |
| 审批通过,付款特批待发起,可在详情页重推合规 | 特批尚未发起(并发跳过或前置条件不满足) | 当前页面没有重推按钮;联系实施检查模板、合同合规ID后重推 |
| 审批通过,但发起付款特批/推送合规失败:xxx,可稍后重推合规 | 审批已生效,特批发起或推送失败 | 按原因修复后联系实施重推,**不要重复审批** |
| 审批通过,已同步金蝶:xxx / 审批通过,但自动同步金蝶失败:xxx | 未走特批,直接同步金蝶的结果 | 金蝶失败按提示原因处理 |
@@ -497,8 +524,8 @@ TMS 合同审批通过后,合同会推送到桂物合同合规系统(HCS)
**特批通过后:**
1. 当前节点恢复“审批通过”。
2. 系统自动补推履约计划(C103-9)和履约记录(C108,若开启),并自动同步金蝶。
1. 当前节点恢复“审批通过”,系统自动同步金蝶付款申请。
2. **金蝶实际付款后**,系统才自动推送履约计划(C103-9,受 `exe-stage.sync-enabled` 控制)和履约记录(C108,若开启):付款审批通过只做本地计划金额累加,未实付时推送结果提示“金蝶未实际付款,待付款后推送履约计划与履约记录”,属正常等待,不是推送失败。
3. 金蝶单据号、金蝶单据状态稍后回填,可刷新列表查看。
**特批驳回后:**
@@ -525,25 +552,56 @@ HCS 正常情况下通过 `paymentLegal/result` 回传结果。长时间停留
任一条件不满足时,审批仍会成功,但特批发起失败;按审批提示或日志修复后重推。
### 3.12 客商档案客商风险(查看与筛选)
**页面:客商管理 → 客商档案(`/vehicle/customer-archive`)**
客商风险数据每天 02:30 自动刷新(合同期限内生效合同的甲乙方关联客商),供客商档案展示、筛选,以及合同/付款环节风险管控使用。
![客商档案列表:展开搜索后可见“客商风险”下拉筛选,列表客商风险列为等级+黑名单组合标签](./2026-10-23-HCS合同管理操作手册(纯操作版).assets/16-客商档案列表-客商风险.png)
**查看规则:**
| 客商情况 | 显示 | 颜色 |
|---|---|---|
| 等级 + 黑名单命中(组合展示,等级在前) | 高风险、黑名单 / 中风险、黑名单 / 低风险、黑名单 | 红 |
| 仅黑名单命中(无等级数据) | 黑名单 | 红 |
| 仅高风险 | 高风险 | 红 |
| 中风险 | 中风险 | 黄 |
| 低风险 | 低风险 | 绿 |
| 内部组织客商 | 无风险(默认,不查询) | 绿 |
| 尚未查询到数据 | - | — |
**操作:**
1. 筛选:搜索栏点 **展开**,“客商风险”下拉选黑名单/高风险/中风险/低风险后点 **查询**。
2. 查明细:红色标签 **鼠标悬浮**,展示失信类别、列入事由、风险等级。
3. 当天新增客商:在新增/编辑客商表单填写客商名称后失焦,系统实时查询并在名称下方显示红色风险提示(悬浮见明细);列表落库数据次日刷新后生效。
![客商风险悬浮明细:被列入外部黑名单(失信类别、列入事由)与风险等级](./2026-10-23-HCS合同管理操作手册(纯操作版).assets/17-客商风险悬浮明细.png)
> 合同列表“客商风险”列与本页同口径,实时关联客商档案组装(见 3.5 节截图)。数据未刷新或金蝶黑名单类别/事由缺失时只显示等级段,等待次日刷新或联系运维执行刷新。
---
## 4. 定时任务与接口(运维视角)
HCS 推送由五个独立定时任务和一组手动接口组成。五个任务各有自己的开关与 cron,互不影响;定时任务、手动接口和审批即时推送共用同一套推送逻辑。
HCS 推送由五个推送定时任务、一个客商风险每日刷新任务和一组手动接口组成。五个推送任务各有自己的开关与 cron,互不影响;定时任务、手动接口和审批即时推送共用同一套推送逻辑。客商风险刷新任务独立于推送框架(不抢推送锁、不走合规状态回写),详见 4.8。
### 4.1 五类定时任务总览
### 4.1 定时任务总览
| 任务 | bizType | 扫描对象 | 推送链路(HCS 接口) | 独立开关 | 默认 cron | 手动触发 |
|---|---|---|---|---|---|---|
| 项目立项 C102 | `project` | 项目立项:审批通过/变更审批通过,待推送/失败未超限 | C102-3 判重 → C102-1 新建 / C102-2 更新 | `push.project.sync-enabled`(默认 `false`) | `0 */5 * * * ?` | `POST /hcs/sync/project/{id}`、`/hcs/sync/project`、`/hcs/sync/project/batch` |
| 合同 C103 | `contract` | 合同:审批通过/变更审批通过,待推送/失败未超限;同轮补查特批结果 | C101-1 风险 → C103-2 附件 → C103-3 判重 → C103-1 起草 / C103-11 更新 → C103-9 履约规则(开关)→ 高风险挂特批 | `push.contract.sync-enabled`(默认 `false`) | `0 1/5 * * * ?` | `POST /hcs/sync/contract/{id}`、`/hcs/sync/contract`、`/hcs/sync/contract/batch` |
| 付款链 C106/C108 | `payment` | 付款申请:审批通过,待推送/失败未超限 | C106-3/C106-4 特批(`hcs_spec_status=need` 时)→ C103-9 履约规则重推 → C108-1/C108-2 履约记录(开关) | `push.payment.sync-enabled`(默认 `false`) | `0 2/5 * * * ?` | `POST /hcs/sync/payment/{id}`、`/hcs/sync/payment` |
| 合同 C103 | `contract` | 合同:审批通过/变更审批通过,待推送/失败未超限;同轮补查特批结果 | C101-1 风险 → C103-2 附件 → C103-3 判重 → C103-1 起草 / C103-11 更新(含业务合同号、归档状态)→ C103-9 履约规则(开关)→ 高风险挂特批 | `push.contract.sync-enabled`(默认 `false`) | `0 1/5 * * * ?` | `POST /hcs/sync/contract/{id}`、`/hcs/sync/contract`、`/hcs/sync/contract/batch` |
| 付款链 C106/C108 | `payment` | 付款申请:审批通过,待推送/失败未超限 | C106-3/C106-4 特批(`hcs_spec_status=need` 时)→ 金蝶实际付款门禁 → C103-9 履约规则重推(开关)→ C108-1/C108-2 履约记录(开关) | `push.payment.sync-enabled`(默认 `false`) | `0 2/5 * * * ?` | `POST /hcs/sync/payment/{id}`、`/hcs/sync/payment` |
| 开票登记 C111 | `invoice-application` | 开票申请:审批通过,待推送/失败未超限 | C111-1 判重 → C111-2 新增 → C111-3 更新(金蝶已开票/已有ID) | `push.invoice-application.sync-enabled`(默认 `false`) | `0 3/5 * * * ?` | `POST /hcs/sync/invoice-application/{id}`、`/hcs/sync/invoice-application` |
| 收票单 C109 | `invoice-receipt` | 收票单:审批通过,待推送/失败未超限 | C109-1 新建 / C109-2 更新 | `push.invoice-receipt.sync-enabled`(默认 `false`) | `0 4/5 * * * ?` | `POST /hcs/sync/invoice-receipt/{id}`、`/hcs/sync/invoice-receipt` |
| 客商风险刷新(C101-1 + A801-5) | — | 合同期限内生效合同的甲乙方关联客商 | 逐个 C101-1 风险等级 + 批量 A801-5 金蝶黑名单,落库客商档案 | `risk.refresh-enabled`(默认 `false`) | `0 30 2 * * ?` | 无手动接口(可临时改 cron 或调 `refreshCustomerRiskForActiveContracts()`) |
> - **即时推送**:项目立项、合同审批通过后立即推送(受 `push.immediate-enabled` 控制);付款申请审批通过后立即发起特批/推送履约数据(**不受**该开关控制);开票登记、收票单没有即时推送,只能靠定时扫描或手动接口。
> - 五个扫描开关默认全部 `false`,按业务灰度打开;手动接口不受开关限制。
> - 只有合同扫描任务额外承担“特批结果兜底查询”,其他任务没有这个动作。
> - **即时推送**:项目立项、合同审批通过后立即推送(受 `push.immediate-enabled` 控制);付款申请审批通过后立即发起付款特批/推送合规(**不受**该开关控制,但履约计划与履约记录要等金蝶实际付款后才推送);开票登记、收票单没有即时推送,只能靠定时扫描或手动接口。
> - 五个推送扫描开关默认全部 `false`,按业务灰度打开;客商风险刷新开关 `risk.refresh-enabled` 同样默认 `false`(上线需置 `true`);手动接口不受推送开关限制。
> - 只有合同扫描任务额外承担“特批结果兜底查询”,其他推送任务没有这个动作。
### 4.2 公共执行框架
@@ -592,10 +650,10 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
**推送逻辑**:
1. **特批挂起门禁**:`hcs_spec_status=processing` 的合同跳过推送,等待特批结果;手动重推直接报“合同特批审核中,禁止重推”。
2. **客商风险 C101-1**(`contract.require-risk-check=true`):甲乙双方分别查询;客商档案匹配不到或为内部组织时跳过该方,不阻断;任一方高风险或黑名单只做标记,**不阻断推送**,推送成功后自动转特批。
2. **客商风险 C101-1**(`contract.require-risk-check=true`):甲乙双方分别查询;客商档案匹配不到或为内部组织时跳过该方,不阻断;任一方高风险(C101-1 评级)或金蝶黑名单命中(A801-5,`HcsCreditCheckSupport` 公共判定)只做标记,**不阻断推送**,推送成功后自动转特批。
3. **附件转传 C103-2**(`attachment.transfer-enabled=true`):附件转传失败会导致整次推送失败。
4. **首次推送**:无合规合同ID时先调 C103-3 判重(`fdNumber=合同编号`);命中直接回填已有ID并置已推送,未命中调 C103-1 起草;起草成功后立即回填合规合同ID,再继续后续步骤。
5. **变更推送**:已有合规合同ID时调 C103-11 更新。
4. **首次推送**:无合规合同ID时先调 C103-3 判重(`fdNumber=合同编号`);命中直接回填已有ID并置已推送,未命中调 C103-1 起草;起草成功后立即回填合规合同ID,再继续后续步骤。报文含业务合同号(`fdBusContractNumber`,取值同合同编号)与归档状态(`fdArchiveStatus`,已上传双章归档文件时传 `toarchive` 待归档)。
5. **变更推送**:已有合规合同ID时调 C103-11 更新,报文同样携带业务合同号与归档状态;已推送合同上传双章归档文件后也会即时单独触发一次 C103-11 归档状态更新(见 3.9 节)。
6. **履约规则 C103-9**(`exe-stage.sync-enabled=true`):按履约计划行级新增/更新/删除;无可同步行时返回“履约规则无待同步行”;同步失败会把整条合同置 `failed`。由于第 4 步已先回填合规ID,下一轮重推会走 C103-11 更新,不会重复起草。
7. **高风险挂起特批**:风险命中时置 `hcs_spec_status=processing`、当前节点“特批审核”,等待 HCS 特批结果。
@@ -628,9 +686,10 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
**推送逻辑**:
1. `hcs_spec_status=need`:无特批流程ID(`hcs_spec_fd_id`)时调 C106-3 发起特批,已有特批流程ID时调 C106-4 更新。C106-3 依赖 `spec-pay.template-id` 与关联合同的合规合同ID;成功后回填特批流程ID、置 `processing`、当前节点“特批审核”。
2. 关联合同已推送合规(合同 `hcs_fd_id` 非空)时,调 C103-9 重推履约规则;无待同步行自动跳过。该动作**不受** `exe-stage.sync-enabled` 控制,与合同任务不同。
3. `perf-record.sync-enabled=false`:直接置整体 `synced` 结束;`true`:无履约记录ID调 C108-1 新建,已有调 C108-2 更新。
4. 整体合规状态 `synced` 只代表本轮推送完成,**不代表特批已通过**;特批中(`processing`)的付款也可能显示已推送。
2. **金蝶实际付款门禁**:付款审批通过只做本地计划金额累加,`paid_amount` 为空或 ≤0 时履约计划与履约记录均不推送,整体照常回写 `synced`,结果说明为“金蝶未实际付款,待付款后推送履约计划与履约记录”;金蝶付款回写推进后会重新置待同步,由扫描任务自动触发再次推送。
3. 关联合同已推送合规(合同 `hcs_fd_id` 非空)且通过实付门禁时,调 C103-9 重推履约规则;无待同步行自动跳过。该动作受 `exe-stage.sync-enabled` 控制,与合同链、收款链一致。
4. `perf-record.sync-enabled=false`:直接置整体 `synced` 结束;`true`:无履约记录ID调 C108-1 新建,已有调 C108-2 更新。
5. 整体合规状态 `synced` 只代表本轮推送完成,**不代表特批已通过、也不代表已实际付款**;特批中(`processing`)或未实付的付款也可能显示已推送。
**特批结果**:HCS 通过 `paymentLegal/result` 回调,或用 `/hcs/spec-pay/confirm` 人工确认。通过后恢复“审批通过”并重新触发金蝶自动同步;驳回后付款审批状态置 `returned`(已驳回),需修改后重新提交审批、重新发起特批。
@@ -686,7 +745,22 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
| `POST /hcs/sync/invoice-receipt/{id}` | 单条重推 | 仅 `approved` 可推;先重置 `unsynced`、失败次数清零 |
| `POST /hcs/sync/invoice-receipt` | 按扫描逻辑推一轮 | 不检查开关、不抢任务锁;**无批量 ID 接口** |
### 4.8 配置项
### 4.8 客商风险每日刷新任务(C101-1 + A801-5)
**任务类**:`CustomerRiskRefreshTask`;**开关**:`risk.refresh-enabled`(默认 `false`,上线需置 `true`);**调度**:`risk.refresh-cron`(默认 `0 30 2 * * ?`,每天 02:30,错开金蝶客商同步 02:00)。
**刷新范围**:合同期限内(起止日期或临时期限覆盖当天)且审核通过、未终止的合同,其甲乙方关联客商;内部组织客商跳过(默认无风险)。
**执行逻辑**:
1. 一次 SQL 取期限内生效合同的甲乙方名称,按名称反查客商档案。
2. 外部客商按 **200/批**:每批调一次 A801-5 金蝶黑名单批量查询 + 逐个调 C101-1 风险等级查询(该接口仅支持单编号)。
3. 批内结果立即落库 `blade_customer_archive`(`hcs_risk_level`、`kingdee_black_status`、`hcs_risk_time`、`kingdee_black_category`、`kingdee_black_reason`)并留痕 `blade_hcs_check_log`;**批间互不影响,单批失败不中断其余批次**。
4. 该任务不走推送框架:不抢推送任务锁、不改单据合规状态、无手动触发接口;临时补刷可把 `refresh-cron` 改为近时刻执行后改回,或调用 `HcsCreditCheckSupport#refreshCustomerRiskForActiveContracts()`。
**结果用途**:客商档案/合同列表“客商风险”列展示与筛选、红色标签悬浮明细、合同推送与付款提交的风险判定数据源。查询逐次留痕,日志关键字“定时刷新客商风险”。
### 4.9 配置项
> 下表键名省略公共前缀 `thirdParty.hcs.`。例如 `push.immediate-enabled` 的完整键为 `thirdParty.hcs.push.immediate-enabled`。
@@ -708,7 +782,7 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
| `attachment.file-download-base` | 空 | TMS 文件下载基址 | 有附件时转传失败 |
| `master-data.ttl-minutes` | `120` | 模板/分类/币种缓存时长 | 主数据变更最长延迟 120 分钟生效 |
**五个任务开关与 cron**
**五个推送任务与风险刷新开关、cron**
| 任务 | 开关(默认) | cron 键 | 默认 cron |
|---|---|---|---|
@@ -717,16 +791,17 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
| 付款链 | `push.payment.sync-enabled`(`false`) | `push.payment.cron` | `0 2/5 * * * ?` |
| 开票登记 | `push.invoice-application.sync-enabled`(`false`) | `push.invoice-application.cron` | `0 3/5 * * * ?` |
| 收票单 | `push.invoice-receipt.sync-enabled`(`false`) | `push.invoice-receipt.cron` | `0 4/5 * * * ?` |
| 客商风险刷新 | `risk.refresh-enabled`(`false`,上线置 `true`) | `risk.refresh-cron` | `0 30 2 * * ?` |
> 五个开关默认全关,按业务灰度打开;cron 不配置时使用代码默认值(每 5 分钟,分钟位 0–4 错峰)。
> 五个推送开关默认全关,按业务灰度打开;客商风险刷新上线需单独开启。推送 cron 不配置时使用代码默认值(每 5 分钟,分钟位 0–4 错峰)。
**各任务业务配置**
| 配置键 | 默认/样例 | 影响任务 | 说明 |
|---|---|---|---|
| `project.template-code` | `template_15x0og` | 项目立项 C102-1 | 未配置时推送失败 |
| `contract.template-mapping` | 样例仅 `[承运商合同]` | 合同 C103-1 | 合同类别未映射必失败;中文键必须写成 `"[承运商合同]"` |
| `contract.currency-name-mapping` | `RMB→人民币`(另一环境样例 `CNY`) | 合同 C103-1 | 币种传错或为空;上线前与合规方确认取值 |
| `contract.template-mapping` | 样例含 `[承运商合同]`、`[客户合同]` | 合同 C103-1 | 合同类别未映射必失败;中文键必须写成 `"[承运商合同]"` |
| `contract.currency-name-mapping` | `RMB`/`CNY`→人民币,`USD`→美元 | 合同 C103-1 | 币种传错或为空;上线前与合规方确认取值 |
| `contract.require-risk-check` | `true` | 合同 C101-1 | 关闭后不做风险校验,高风险合同不会触发特批 |
| `exe-stage.sync-enabled` | `false` | 合同 C103-9 | 关闭时合同任务不推履约规则 |
| `exe-stage.fund-type` | `byAmount` | 合同 C103-9 | 金额类型传错会被合规校验拒绝 |
@@ -745,20 +820,22 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
> 不同环境样例中的币种名称映射可能不同(`CNY` 与 `人民币`),上线前必须与合规方确认 HCS 主数据实际取值。
### 4.9 运维注意
### 4.10 运维注意
- **开关灰度**:五个扫描开关默认全关且互相独立;项目立项/合同的即时推送受 `push.immediate-enabled` 控制,付款审批即时特批不受该开关控制;手动接口始终可用,不受开关限制。
- **开关灰度**:五个推送扫描开关默认全关且互相独立;项目立项/合同的即时推送受 `push.immediate-enabled` 控制,付款审批即时特批不受该开关控制;客商风险刷新开关 `risk.refresh-enabled` 上线需单独置 `true`;手动接口始终可用,不受推送开关限制。
- **先看存量再开扫描**:扫描不区分“从未推送”和“推送失败”,开关一开,所有审批通过且未同步的历史单据都会进入扫描范围(每轮最多 50 条)。上线前先确认待推送数据量。
- **修复根因优先**:失败单据每 5 分钟自动重试,达到 `push.max-retry`(默认 5)后停止;人工重推会清零次数,但不修复配置/数据会反复失败。
- **付款依赖合同**:C106-3 要求关联合同已有合规合同ID,C103-9 也以合同 `hcs_fd_id` 为前提;合同未推送合规时付款特批/履约规则相关步骤会失败或跳过。
- **付款履约数据看实付**:付款链履约计划/履约记录以金蝶实际付款为门禁,未实付时整体状态照常回写 `synced`,不要把“金蝶未实际付款,待付款后推送…”当失败处理。
- **客商风险刷新依赖合同范围**:刷新范围是合同期限内生效合同的关联客商,期限外客商风险列显示“-”属正常;金蝶黑名单类别/事由来自 A801-5,缺失时列表只显示等级段。客商黑名单与风险等级专项说明见《2026-10-30-客商黑名单与风险等级运维手册(运维侧)》(doc/docs/业务流程/)。
- **开票必配币种映射**:`invoice.currency-id-mapping` 的 `RMB` 必须配置为 HCS 币种 fdId,否则开票登记每轮都会失败。
- **收票合同关联**:回溯不到合同且未人工补录 `hcs_contract_id` / `hcs_contract_no` 时不会失败,但合同字段为空;需要完整合同信息时先补录再重推。
- **模板映射逐个类别确认**:样例只配了承运商合同;客户合同、补充协议未配置时推送必失败。Nacos YAML 中文键必须写成 `"[承运商合同]"`。
- **模板映射逐个类别确认**:样例只配了承运商合同、客户合同;补充协议未配置时推送必失败。Nacos YAML 中文键必须写成 `"[承运商合同]"`。
- **附件下载基址必配**:合同有附件时,`attachment.file-download-base` 为空会导致附件转传失败。
- **生产禁用 Mock**:`thirdParty.hcs.mock-enabled` 生产环境必须为 `false`。
- **主数据刷新**:模板映射调整后,可调用 `POST /hcs/master-data/refresh` 刷新模板/分类/币种缓存并校验映射。
- **多实例与手动接口**:定时任务按 bizType 抢 Redis 锁(TTL 240 秒),抢不到本轮跳过;手动接口不抢锁,避免与定时任务同一时刻执行。
- **日志排查**:服务日志按单据编号、合规ID、`hcs` 关键字检索;本地日志目录为 `F:\home\tms\blade-transport`。
- **日志排查**:服务日志按单据编号、合规ID、`hcs` 关键字检索;客商风险刷新日志按“定时刷新客商风险”检索;本地日志目录为 `F:\home\tms\blade-transport`。
---
@@ -775,7 +852,7 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
| 提示“合同类别 [xxx] 未配置合规模板ID” | Nacos 模板映射缺该类合同 | 运维补齐 `contract.template-mapping` 后重推 |
| 提示附件转传失败 | 下载基址为空、附件不可下载、文件超限或 HCS 附件接口异常 | 检查 `attachment.file-download-base` 和附件,修正后重推 |
| 提示 C101-1 客商风险查询失败 | HCS 风险接口异常/无响应 | 联系 HCS 侧确认接口,恢复后重推 |
| 高风险客商没有触发特批 | 客商名称与档案不匹配导致风险校验被跳过,或 HCS 返回的风险等级不是高风险/黑名单 | 核对甲乙方名称与客商档案;在 HCS 侧确认客商风险等级和黑名单状态 |
| 高风险客商没有触发特批 | 客商名称与档案不匹配导致风险校验被跳过,或风险等级不是高风险且未命中金蝶黑名单 | 核对甲乙方名称与客商档案;黑名单以金蝶黑名单命中(A801-5)为准,核对客商档案金蝶黑名单字段与 HCS 风险等级 |
| 提示 C103-1/C103-11 合同发起/更新失败 | HCS 业务校验不通过 | 按返回 msg 修正模板、字段或主数据后重推 |
| 特批状态一直是“待特批” | 存量数据或推送未成功,未进入特批流程 | 联系管理员重新推送;推送成功后会自动转“特批中” |
| 特批中想重推 | 特批流程未结束 | 不允许重推,等待 HCS 回传或人工确认结果 |
@@ -799,6 +876,9 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
| 付款审批提示“已发起付款特批”但迟迟没有金蝶单据 | 特批仍在审批,特批通过后才同步金蝶 | 等待特批结果;长时间无结果联系实施人工确认 |
| 付款特批发起失败(模板未配/合同未推送) | `spec-pay.template-id` 为空或关联合同无合规ID | 运维修复后重推;不要重复审批 |
| 付款特批中能否重复审批 | 特批中审核状态已是“审批通过” | 不需要也不能重复审批;等待 HCS 结果 |
| 客商档案/合同列表“客商风险”列显示“-” | 客商不在合同期限内生效合同关联范围,或档案刚创建未被每日刷新任务查询过 | 等次日 02:30 刷新;当天可在客商表单按名称实时查询风险提示 |
| 客商命中金蝶黑名单但列表没有“黑名单” | 黑名单展示依赖金蝶记录的失信类别/列入事由(每日刷新落库),数据缺失时只显示等级段 | 等待次日刷新或联系运维执行刷新;确认 `risk.refresh-enabled` 已开启 |
| 付款特批通过后合规里没有履约计划/履约记录 | 付款链履约数据以金蝶实际付款为门禁,未实付不推送 | 正常等待金蝶付款回写后自动推送;整体状态显示“金蝶未实际付款,待付款后推送履约计划与履约记录”属正常 |
---
@@ -825,6 +905,8 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
| 付款申请表单 | 附件类型“特批附件” | 高风险/黑名单收款方提交前必传 |
| 付款申请列表 | 行内“通过” | 审批通过后自动发起付款特批 |
| 付款申请列表 | “当前节点”列 | 查看“特批审核/审批通过/已驳回”判断特批进度 |
| 客商档案列表 | 搜索栏“客商风险”筛选 | 按黑名单/高风险/中风险/低风险筛选客商(数据每日 02:30 刷新) |
| 客商档案列表 | 客商风险列红色标签悬浮 | 查看失信类别、列入事由、风险等级明细 |
| 运维专用 | `POST /hcs/sync/project/{id}` | 单条重推项目立项 |
| 运维专用 | `POST /hcs/sync/project/batch` | 按勾选ID批量重推项目立项 |
| 运维专用 | `POST /hcs/spec-pay/confirm` | 人工确认付款特批结果(参数 `id`、`result`;页面无按钮) |
@@ -838,4 +920,4 @@ HCS 推送由五个独立定时任务和一组手动接口组成。五个任务
---
> 本文基于 2026-10-23 代码与页面现状整理;按钮、提示和环境开关以实际页面为准。临时合同推送、旧版“发起特批”入口、变更页“终止合同”未开放属于当前版本现状,后续版本调整时请同步核对。
> 本文基于 2026-10-23 代码与页面现状整理,2026-11-01 按最新实现更新(客商风险口径、归档即时同步、付款履约实付门禁、客商风险刷新任务);按钮、提示和环境开关以实际页面为准。临时合同推送、旧版“发起特批”入口、变更页“终止合同”未开放属于当前版本现状,后续版本调整时请同步核对。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

@@ -1,23 +1,30 @@
# 金蝶(星瀚)BFI 全链路梳理:前端入口 → 后端链路 → 定时任务(业务域汇总 + 操作手册)
> 整理日期:2026-10-14
> 代码基线:`tms-api` 当前 master(`805ca9d3`,2026-10-13),全量按当前代码重新核实(上一版总纲基线为 09-24 `50573e7`)。
> 整理日期:2026-10-14(2026-10-31 修订)
> 代码基线:`tms-api` 分支 feature/20261031-bfi-收票收款改造(`933fb37d`,2026-10-31)。
> 本文替代《2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版)》作为新总纲:
> - **组织方式改为三层**:每个业务域按「前端入口(页面/按钮)→ 后端链路(触发/顺序/口径/落库)→ 定时任务」梳理;
> - 新增**操作手册**(第 11 节):按业务时序给出端到端操作步骤与异常处理操作;
> - 自 09-27 以来代码有 40+ 提交(09-28~10-13),**收票回写缺陷已修、调试残留已清、新增锁单/解锁、审批自动同步、failed 重推、票池镜像读回切、全票池定时同步、A801-5 黑名单、A801-2/A902 附件接线、收款认领重构**等,接口清单、分录口径、状态机均有实质变化,全文按当前代码重写。
> - 新增**操作手册**(第 11 节):按业务时序给出端到端操作步骤与异常处理操作。
## 修改记录
| 日期 | 代码基线 | 修订内容 |
|---|---|---|
| 2026-10-14 | master `805ca9d3`(10-13) | 初版总纲,替代 09-27 版;全量按代码重写 |
| 2026-10-15 | master | 增补(更新要点、入参映射表交叉引用等) |
| 2026-10-31 | feature/20261031-bfi-收票收款改造 `933fb37d`(10-31) | ① **第 5 节收票域整体重写**:收票登记改「先票后款」两段式(收票登记只推 A303-2 终态 pushed、A302-6 指定应付移到付款结算单侧,A303-2 报文按发票明细行组装、往来户=销方供应商代码、结算组织页面手选、币别固定 CNY);② **第 6 节收款认领整体重写**:数据源 A301-2→A301-1,A301-2/A301-3 推送链路与定时任务整体下线,认领只登记本地并推送合同合规(C103-9/C107-1/C108-1,作废推 C107-3);③ **2 节客商域**:新增 A901-1 供应商编码查询落库、A905-3 行名行号查询(收款账户联行号带出/保存校验);④ **3 节付款域**:金蝶实际付款(A103-2 paid 回写)后才推送 HCS 付款履约计划/履约记录;⑤ 1.2 接口清单、1.3 防重键、8 全景、9 状态机、10 定时任务、11 操作手册、12 风险清单同步更新;⑥ 补充 TMS 侧新页面截图(收票登记先票后款表单、付款结算单发票登记、收款流水 A301-1 列表、认领记录、客商供应商编码),下线 A301-2/A301-3 金蝶侧旧效果截图 |
---
## 目录
- [0. 本次更新要点(09-27 → 10-14)](#0-本次更新要点09-27--10-14)
- [0. 本次更新要点(10-14 → 10-31 与 09-27 → 10-14)](#0-本次更新要点10-14--10-31-与-09-27--10-14)
- [1. 集成总览与公共机制](#1-集成总览与公共机制)
- [2. 业务域一:客商档案(A902-1 / A902-2 / A801-5 黑名单)](#2-业务域一客商档案a902-1--a902-2--a801-5-黑名单)
- [2. 业务域一:客商档案(A902-1 / A902-2 / A901-1 供应商 / A905-3 行名行号 / A801-5 黑名单)](#2-业务域一客商档案a902-1--a902-2--a901-1-供应商--a905-3-行名行号--a801-5-黑名单)
- [3. 业务域二:财务应付与付款(A102-2 / A102-4 / A103-1 / A103-3 / A103-2 / A103-4)](#3-业务域二财务应付与付款)
- [4. 业务域三:财务应收与开票(A302-2 / A302-4 / A303-4 / A303-1 / A303-5)](#4-业务域三财务应收与开票)
- [5. 业务域四:收票与金蝶票池(A801-1 / A303-2 / A302-6 / A801-3 / A801-2 / A902 附件)](#5-业务域四收票与金蝶票池)
- [6. 业务域五:收款认领(A301-2 / A301-3 / A301-1)](#6-业务域五收款认领a301-2--a301-3--a301-1)
- [5. 业务域四:收票与金蝶票池(先票后款两段式)](#5-业务域四收票与金蝶票池先票后款两段式)
- [6. 业务域五:收款流水与认领(A301-1 数据源 + 合同合规推送)](#6-业务域五收款流水与认领a301-1-数据源--合同合规推送)
- [7. 业务域六:公共取数(A801-4 汇率)](#7-业务域六公共取数a801-4-汇率)
- [8. 全景:接口依赖与先后顺序](#8-全景接口依赖与先后顺序)
- [9. 状态机汇总](#9-状态机汇总)
@@ -27,7 +34,22 @@
---
## 0. 本次更新要点(09-27 → 10-14)
## 0. 本次更新要点(10-14 → 10-31 与 09-27 → 10-14)
### 0.1 本次更新要点(10-14 → 10-31,本次修订)
| # | 变化 | 说明 |
|---|---|---|
| 1 | **★★收票登记改「先票后款」两段式(禅道 #15850)** | 收票登记不再关联结算单/合同:从票池选票 → 手选结算组织(承办部门)→ 推 A303-2 生成金蝶收票单即**终态 pushed**(删除 A102-4 应付预检与推送时立即 A302-6);A302-6 指定应付移到**付款结算单侧**——结算单编辑/发票登记认领收票单(候选=审批通过+未认领,全额认领),A102-2 应付单推送成功后逐单指定;A801-3「指定补偿」升级为重试引擎(pushed+已认领→补发 A302-6;未认领→跳过不置 failed)。专项文档:`2026-10-31-先票后款改造方案V2….md` |
| 2 | **★A303-2 报文按先票后款重写** | 主表:往来户=发票销方的金蝶供应商代码(登记时尽力回填、推送前强校验,取自客商档案 `supplier_code`);结算组织=页面手选部门沿部门树上溯平台公司金蝶组织编码;币别固定 CNY。分录:**逐行取票池镜像发票明细**(商品编码=物料/税收分类编码、"V+整数税率"、单价统一 4 位小数、分录候选键=发票号码_seq) |
| 3 | **★全额认领放开匹配合计上限、收票状态按覆盖判定** | 结算单认领收票单不再校验「发票匹配合计≤结算金额」;结算单收票状态按 `已匹配金额 ≥ 结算金额` 判 completed(覆盖即完成,允许超额) |
| 4 | **★★收款认领链路整体下线 A301-3,数据源 A301-2→A301-1** | 收款流水菜单数据源改为 **A301-1 收款单查询**(手动同步,窗口默认 60 天,全公司口径);认领只登记本地(额度上限=收款金额 actrecamt),**不再推送金蝶**;认领事务提交后推合同合规 C103-9→C107-1→C108-1,作废推 C107-3;`ReceiptClaimPushTask` 定时、`/push-kingdee`、`/query-kingdee-result` 端点、A301-2 公告同步全部删除。专项文档:`2026-10-29-收款流水菜单改造….md`、`2026-10-29-收款流水认领操作流程….md` |
| 5 | **★A901-1 供应商查询启用** | `bd_supplier/query` 按统一社会信用代码查金蝶供应商编码:客商新增/金蝶同步复查回填 `supplier_code`,手动端点 `GET /customer-archive/query-supplier-code`;先票后款 A303-2 往来户取值的数据前提 |
| 6 | **★A905-3 行名行号查询启用** | `bd_bebank/query`:客商收款信息开户行失焦按行名精确匹配带出联行号(`GET /customer-archive/query-bank`);保存前按行号反查校验(`GET /customer-archive/query-bank-by-number`,查不到拦截保存) |
| 7 | **★金蝶实际付款后才推 HCS 付款履约计划/记录** | A103-2 回写付款推进(paid 金额变化)时置付款申请 `hcsSyncStatus=unsynced`,由 HCS 推送任务在**金蝶实付门禁**(paidAmount>0)通过后推 C103-9/C108-1(`HcsPaymentSyncSupport`);合同链首推时点不变 |
| 8 | **收票域辅助端点** | `GET /invoice-receipt/invoice-detail-query?invoiceNo=`(表单带出发票明细,镜像缺明细时按票号回源);`GET /invoice-receipt/claim-candidates?keyword=`(付款结算单选收票单候选,审批通过+未被认领,limit 200);`POST /formal-settlement/invoice-assign-kingdee`(发票登记保存后手动触发指定应付) |
### 0.2 历史更新要点(09-27 → 10-14,初版总纲)
| # | 变化 | 说明 |
|---|---|---|
@@ -66,14 +88,14 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
金蝶星瀚 /ierp/kapi/...
反向:金蝶删单 ──▶ POST /blade-transport/kingdee-bfi/unLockNotifyBusiData(免登 skip-url)
定时:blade-transport task/ 包 7 个任务(Spring @Scheduled,全库无 PowerJob/XxlJob 接线)
定时:blade-transport task/ 包金蝶相关任务 5 类(Spring @Scheduled,全库无 PowerJob/XxlJob 接线)
```
- 无 Feign 重试(NEVER_RETRY)、无 fallback;失败抛 `BfiException`,批量循环逐条 catch,单条失败不中断整批。
- 令牌:Redis `blade:third:bfi:token`,TTL=expires_in−120s;`setIfAbsent` 写入(已有缓存不覆盖)。
- 配置前缀两套并存(读同一批 Nacos 键):bfi-api `third-party.bfi.*`(baseUrl/认证/令牌),transport `thirdparty.bfi.*`(业务开关/口径/映射)。样例:`doc/nacos/third-party-api.yaml`。
### 1.2 接口清单总表(2026-10-14,含在用状态)
### 1.2 接口清单总表(2026-10-31,含在用状态)
来源:`blade-bfi-api/.../feign/IBfiClient.java`(URL 可被 `thirdParty.bfi.*Url` 覆盖)。
@@ -81,35 +103,37 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
|---|---|---|---|
| A100-1 | POST /ierp/kapi/oauth2/getToken | 获取令牌 | ✅ 自动(每次调用前) |
| A100-2 / A100-3 | verifyToken / withdrawToken | 校验/撤回令牌 | ❌ 已实现无调用 |
| A102-2 | POST …/awrp/ap/ap_finapbill/save | 财务应付单保存 | ✅ 正式结算单(应付)推送 |
| A102-4 | GET …/awrp/ap/ap_finapbill/query | 财务应付单查询 | ✅ 预检 / 硬校验 / 批量预热 |
| A901-1 | POST …/basedata/bd_supplier/query | 供应商查询 | ✅ **10-28 新增启用**:按信用代码查金蝶供应商编码(客商建档/同步复查回填 supplier_code、手动端点 query-supplier-code) |
| A905-3 | POST …/basedata/bd_bebank/query | 行名行号查询 | ✅ **10-31 新增启用**:客商收款信息开户行带出联行号 + 保存前按行号校验 |
| A102-2 | POST …/awrp/ap/ap_finapbill/save | 财务应付单保存 | ✅ 正式结算单(应付=付款结算单)推送;推送成功后触发本单认领收票单的 A302-6 指定 |
| A102-4 | GET …/awrp/ap/ap_finapbill/query | 财务应付单查询 | ✅ 付款申请硬校验 / 预检 / 批量预热;**收票链路已不再依赖**(先票后款删除 A102-4 预检) |
| A103-1 | POST …/awrp/ap/ap/ap_finapbill/push | 应付下推生成付款申请 | ✅ 付款申请推送(单分录) |
| A103-2 | GET …/awrp/ap/ap_payapply/query | 付款申请单查询 | ✅ 推送预检 + 状态回写 + 消失解锁计数 |
| A103-2 | GET …/awrp/ap/ap_payapply/query | 付款申请单查询 | ✅ 推送预检 + 状态回写 + 消失解锁计数;paid 回写后触发 HCS 付款履约计划/记录推送(金蝶实付门禁) |
| A103-3 | POST …/awrp/ap/ap/ap_payapply/save | 预付款申请保存 | ✅ 预付款直推 |
| A103-4 | GET …/awrp/cas/cas_paybill/query | 付款处理查询 | ✅ 付款记录回写(含 voucher_json) |
| A301-1 | GET …/awrp/cas/cas_recbill/queryRecBill | 收款单查询 | ✅ 认领结果回写(手动) |
| A301-2 | GET …/awrp/cas/cas_claimannounce/query | 收款认领公告查询 | ✅ 流水同步(手动 + 定时认领推送前置重查) |
| A301-3 | POST …/awrp/cas/cas/cas_claimbill/claimbillPush | 收款认领结果推送 | ✅ 定时 + 手动双入口,按流水归集 |
| A301-1 | GET …/awrp/cas/cas_recbill/queryRecBill | 收款单查询 | ✅ **角色变更**:收款流水菜单数据源(手动同步,窗口默认 60 天);原「认领结果回写」用途随 A301-3 下线作废 |
| A301-2 | GET …/awrp/cas/cas_claimannounce/query | 收款认领公告查询 | ❌ **10-29 下线**(数据源改 A301-1;代码与 `claimAnnounce.*` 配置已删除) |
| A301-3 | POST …/awrp/cas/cas/cas_claimbill/claimbillPush | 收款认领结果推送 | ❌ **10-29 下线**(认领只登记本地+推合同合规;`ReceiptClaimPushTask`、`/push-kingdee` 已删除) |
| A302-2 | POST …/awrp/ar/ar_finarbill/save | 财务应收单保存 | ✅ 正式结算单(应收)推送 |
| A302-4 | GET …/awrp/ar/ar_finarbill/query | 财务应收单查询 | ✅ 预检 / 硬校验 / 定时勾稽纠偏 |
| A302-6 | POST …/awrp/ap/ap/ap_invoice/assign | 收票指定应付 | ✅ 收票推送第二步 |
| A302-6 | POST …/awrp/ap/ap/ap_invoice/assign | 收票指定应付 | ✅ **触发方变更**:由付款结算单侧执行(A102-2 推送成功后逐单指定 / 发票登记推送 / 手动端点 invoice-assign-kingdee / A801-3 指定补偿);收票登记推送链不再调用 |
| A303-1 | POST …/awrp/sim/sim_original_bill/save | 开票申请单新增 | ✅ 按发票张次逐张推送 |
| A303-2 | POST …/awrp/ap/ap_invoice/cusBatchSave | 收票单批量保存 | ✅ 收票登记推送 |
| A303-2 | POST …/awrp/ap/ap_invoice/cusBatchSave | 收票单批量保存 | ✅ 收票登记推送(**先票后款**:报文按发票明细行组装,终态 pushed) |
| A303-4 | GET …/awrp/sim/sim_original_bill/query | 开票申请单查询 | ✅ 批量 billno 幂等预检 / 附件补传反查星瀚内码 |
| A303-5 | POST /ierp/kapi/app/sim/openApi | 开票发票查询(票易通) | ✅ 定时回写开票结果 |
| A801-1 | POST …/awrp/rim/rim_invoice/query | 全票池发票查询(rim 契约) | ✅ 定时全量同步 + 票号回源 + 保存/认领闸门 + 附件回源 |
| A801-2 | POST …/awrp/bosext/attachment/download | 进项发票文件下载 | ✅ **已接线**:附件查看/下载流式代理 |
| A801-3 | GET …/awrp/ap/ap_invoice/apinvoicequery | 收票单查询 | ✅ 推送预检 + 定时回写(防重键已修复=登记 id) |
| A801-1 | POST …/awrp/rim/rim_invoice/query | 全票池发票查询(rim 契约) | ✅ 定时全量同步 + 票号回源 + 保存/认领闸门 + 附件回源 + **发票明细带出(items_json,先票后款分录取数来源)** |
| A801-2 | POST …/awrp/bosext/attachment/download | 进项发票文件下载 | ✅ 已接线:附件查看/下载流式代理 |
| A801-3 | GET …/awrp/ap/ap_invoice/apinvoicequery | 收票单查询 | ✅ 推送预检 + 定时回写(防重键=登记 id);「指定补偿」升级为重试引擎(pushed+已认领→补发 A302-6) |
| A801-4 | GET …/awrp/base/bd_exrate_tree/Forexquery | 汇率查询 | ✅ 结算页面取汇率 |
| A801-5 | POST …/awrp/bdm/bdm_blacklist_config/blacklistquery | 黑名单查询 | ✅ **新增启用**:客商提审校验 |
| A902 | POST /ierp/kapi/v2/frame/attachment/uploadFile | 附件上传 | ✅ **已接线**:4 条推送链路联带上传 |
| A801-5 | POST …/awrp/bdm/bdm_blacklist_config/blacklistquery | 黑名单查询 | ✅ 客商提审校验 |
| A902 | POST /ierp/kapi/v2/frame/attachment/uploadFile | 附件上传 | ✅ 已接线:4 条推送链路联带上传 |
| — | POST /API_lockAction_Router | lockBusiData 单据锁定 | ✅ 推送成功后锁单({updateCount,updateResultList} 应答) |
| — | (回调)POST /kingdee-bfi/unLockNotifyBusiData | 解锁/删单通知(unLockNotifyBusiData) | ✅ 金蝶回调本系统(免登放行) |
> 仍未实现:A901-1 供应商查询、A913 项目、A101-1/2 合同、A102-1/A302-1 暂估单(合同/项目走合同合规系统)。
> 查询时间窗约束:A103-2/A103-4/A102-4/A302-4/A303-4/A301-1 ≤ 1 个月;A902-1 ≤ 6 个月;A301-2 无时间窗参数(按认领人+分页,时间窗口取 `thirdParty.bfi.claimAnnounce.months` 默认 6)。
> 仍未实现:A913 项目、A101-1/2 合同、A102-1/A302-1 暂估单(合同/项目走合同合规系统);A901-1 已实现(查询回填,无推送)。
> 查询时间窗约束:A103-2/A103-4/A102-4/A302-4/A303-4/A301-1 ≤ 1 个月(A301-1 同步按业务日期窗口切片拉取,窗口总天数取 `thirdParty.bfi.recBillSync.days` 默认 60);A301-2/A301-3 已下线。
### 1.3 防重键(候选键)总表(2026-10-14 口径)
### 1.3 防重键(候选键)总表(2026-10-31 口径)
金蝶侧按「来源系统 + 来源单据号」幂等(`awrp_src_sys` + `awrp_src_code`)。组装:`ExternalSrcCodeSupport.buildSrcCode`(下划线拼接、去空白、去重保序)。
@@ -120,11 +144,13 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
| 付款申请 A103-1 | `paymentNo` | 配置 `0KzXs` | 固定 `paymentNo-1`(单分录);勾稽键 `awrp_finapbill_entryid`=主结算单应付防重键(本地组装) | A103-2 |
| 预付款申请 A103-3 | 付款申请号口径 | `0KzXs` | `paymentNo-1` | A103-2 |
| 开票申请 A303-1 | billno:单张=`application_no`,多张=`application_no-张次序号`;`awrp_src_con_no`=首张结算单应收防重键 | `0KzXs` | 开票商品行 id | A303-4(批量 billno) |
| 收票单 A303-2 | **收票登记 id** | TMS收票登记 | 与 A102-2 分录同源同口径 | A801-3(预检与回写同键) |
| 收款认领 A301-3 | **分摊结算单号下划线拼接**(重试复用同一编码)★改 | WLTMS | 单条:seq/e_corebillno=第一张结算单**金蝶内码** | A301-1(sourcebillnumber 匹配) |
| 收票单 A303-2 | **收票登记 id** | TMS收票登记 | 先票后款:逐行镜像明细,`awrp_entry_code`=发票号码_seq(重推幂等) | A801-3(预检与回写同键) |
| ~~收款认领 A301-3~~ | ~~分摊结算单号下划线拼接~~ | WLTMS | — | **10-29 下线**:认领只登记本地+推合同合规,不再推送金蝶 |
| 客户 A902-2 | `number`=TMS 客商编号(KS+6位);`societycreditcode` 为金蝶侧候选键 | awrp_datasourcesys=WLTMS | — | A902-1 |
### 1.4 公共档案取数(应付/应收/收票复用 `KingdeeApBillSyncSupport`)
### 1.4 公共档案取数(应付/应收复用 `KingdeeApBillSyncSupport`;收票先票后款另见第 5.3 节)
> ★收票 A303-2 先票后款改造后不再复用「结算明细拆分」口径:往来户=销方供应商代码(`supplier_code`,A901-1 落库)、结算组织=页面手选部门派生、币别固定 CNY、分录=镜像发票明细行(详见 5.3)。下表为应付/应收(A102-2/A302-2)共用口径。
| 取数项 | 规则(当前代码) |
|---|---|
@@ -186,22 +212,31 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
---
## 2. 业务域一:客商档案(A902-1 / A902-2 / A801-5 黑名单)
## 2. 业务域一:客商档案(A902-1 / A902-2 / A901-1 供应商 / A905-3 行名行号 / A801-5 黑名单)
**定位**:全局数据前提。其他链路的往来户编码(`customer_code`)来自 `blade_customer_archive`;金蝶客户编码 = TMS 客商编号。
**定位**:全局数据前提。其他链路的往来户编码(`customer_code`)与先票后款销方供应商代码(`supplier_code`)均来自 `blade_customer_archive`;金蝶客户编码 = TMS 客商编号。
### 2.1 前端入口
| 页面 | 操作 | 后端端点 | 触发时机 |
|---|---|---|---|
| 客商档案(`/vehicle/customer-archive`) | 新增/编辑表单录统一社会信用代码(满 18 位防抖) | `GET /bfi/customer/query-by-credit-code`(A902-1) | 回填名称/法人/地址/经营范围等并锁定;金蝶状态 A/B 阻断保存 |
| 客商档案(`/vehicle/customer-archive`) | 新增/编辑表单录统一社会信用代码(满 18 位防抖) | `GET /bfi/customer/query-by-credit-code`(A902-1) | 回填名称/法人/地址/经营范围等并锁定;金蝶状态 A/B 阻断保存;**同时按信用代码查金蝶供应商编码回填 `supplier_code`(A901-1)** |
| 同上 | 表单「供应商编码」字段 | `GET /customer-archive/query-supplier-code?creditCode=`(A901-1) | 修改信用代码后即时回显金蝶供应商编码(只读展示,系统自动获取) |
| 同上 | 收款信息「开户行」失焦 | `GET /customer-archive/query-bank?bankName=`(A905-3) | 行名精确匹配金蝶行名行号档案,**带出联行号** |
| 同上 | 收款信息保存前校验 | `GET /customer-archive/query-bank-by-number?cnapsCode=`(A905-3) | 按联行号反查金蝶行名行号档案,查不到拦截保存(接口异常上抛,与「查不到」区分) |
| 同上 | 行内「同步金蝶」链接(仅 approved 且 failed 显示) | `POST /customer-archive/sync-kingdee`(A902-2) | 失败人工重推 |
| 同上 | 行内「审批通过」 | `POST /customer-archive/approve` → 内部 `syncKingdee(id)` | **提审校验 A801-5 黑名单;通过后自动推送 A902-2** |
![客商档案表单:基础信息含「供应商编码」字段(金蝶供应商编码,系统自动获取,A901-1 按信用代码回填);收款信息 tab 维护开户行/联行号(A905-3 行名带出、保存前校验)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/tms-客商供应商编码.png)
### 2.2 后端链路
**A902-1 客户查询(3 入口)**:前端回填 / 单条推送前复查(`syncKingdee` 先查一次,C→exist 终态、A/B→failed)/ 定时批量复查(50 信用代码/批翻页取全)。
**A901-1 供应商查询(★10-28 新增)**:`CustomerArchiveServiceImpl.queryKingdeeSupplierNumber` 按「统一社会信用代码 + enable=启用」调 `bd_supplier/query` 取金蝶供应商 `number`。落库三个入口:① 客商新增建档(提交前);② 金蝶同步复查回填(`fillSupplierCode`,查到才覆盖);③ 手动端点 `GET /customer-archive/query-supplier-code?creditCode=`(前端修改信用代码后即时回显)。查不到/异常返回 null 不阻断——该字段是**先票后款 A303-2 往来户的数据前提**(收票登记销方供应商代码按销方名称匹配本地客商档案 `supplier_code`)。
**A905-3 行名行号查询(★10-31 新增)**:`bd_bebank/query` 查金蝶行名行号档案。两个端点:`GET /customer-archive/query-bank?bankName=`(已审核+启用档案按行名查,pageSize=10,本地全称精确比对取第一条,**失焦带出联行号**,异常返回 null);`GET /customer-archive/query-bank-by-number?cnapsCode=`(按行号精确比对,**保存前校验**,查不到返回 null 由前端拦截保存、接口异常直接上抛)。后端保存仅校验联行号非空/长度,真实性校验由前端调此接口完成。
**A801-5 黑名单(★新增,提审拦截)**:`CustomerArchiveServiceImpl.submitApproval` → 取客商各发票 `taxNo` 去重大写 → `queryBlacklist("1", taxNos)`(enable=1,pageSize 上限 2000,循环翻页取全)→ **A 类硬拦截**;B 类默认放行告警(`thirdparty.bfi.blacklist.blockRiskWarning=true` 时拦截);其他类别放行告警;**查询异常上抛阻断提审**。
**A902-2 客户保存(3 入口收敛 `pushCustomerToKingdee`)**:审批通过自动(失败不影响审批)/ 人工重推(synced/exist 幂等返回)/ 定时重推(pending=approved 且 none/failed)。先 A902-1 复查(金蝶已有 C 直接置 exist 跳过)再保存。只推新增建档(审批后变更不重推)。参数要点:`entry_groupstandard` 按 `customerKind` internal→1101 / 其余→1102;`entry_bank` 取 `blade_customer_receipt_account`(账号/开户行/CNY/默认 + **真实联行号 cnaps_code**,硬编码已删);`bizfunction` 固定 `1,2,3,4`。
@@ -265,6 +300,7 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
![预付款对照:TMS 进度预付申请(240.12)下推金蝶生成「其他付款申请」FKSQ-00000219(进项货付款申请),分录预付款金额、往来户、结算方式对应;附件区可见 A902 联带上传的文件](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-3-预付款对照.png)
- **A103-2 回写状态映射**:`Alreadypay`→paid(终)>`billstatus=D`→closed(终)>`Inpayment`→paying>其余 synced;`entry[].e_paidamt` 汇总→paid_amount(缺失且 paid 兜底=申请金额);paid 联动履约(预结算垫资/结算付款记录);**failed 纳入勾稽纠偏**(查到即纠正 synced);**查不到→消失解锁计数**(仅已生成/付款中参与,连续 2 轮→解锁置 failed)。
- **★HCS 付款履约计划/记录改金蝶实付后推送(10-31)**:A103-2 回写付款推进(首次付款或 paid 金额变化)时将付款申请 `hcs_sync_status` 置回 unsynced,由 HCS 推送任务重新编排;`HcsPaymentSyncSupport` 内置**金蝶实付门禁**——`paidAmount<=0` 直接跳过标记 synced,`paidAmount>0` 才推 C103-9 付款履约计划与 C108-1/2 履约记录(原「未付款跳过履约记录」分支合并为统一门禁)。合同链履约计划首推时点不变;收款认领链的 C103-9 累加的是收款计划行,与本门禁互不影响。
- **A103-4 付款记录**:按付款组织编码分组查询(`org_number` 优先取 A103-2 返回值),窗口最近 1 个月,`sourcebillid`==kingdee_bill_id 优先、`sourcebillnumber` 兜底;upsert `blade_payment_application_record`(actpayamt/paid_date/整单 JSON→voucher_json);清理 MOCK-PAY- 模拟记录。
- **A902 附件**:推送成功事务提交后联带上传(`KingdeeAttachmentSyncSupport.syncUserAttachments`),失败不污染主单状态,重推补传。
- **异常落库**:手动/自动同步失败统一独立事务落 `failed` + 原因(截 500 字),由「手动重推/failed 重推任务」续推。
@@ -311,33 +347,47 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
---
## 5. 业务域四:收票与金蝶票池
## 5. 业务域四:收票与金蝶票池(先票后款两段式)
### 5.1 架构现状(★10-05 回切后终态)
> ★10-31 先票后款改造(禅道 #15850):收票登记拆分为纯「收票」动作(不关联结算单/合同,推 A303-2 即终点);「发票跟结算单的指定」移到**付款结算单**侧完成(认领收票单 → 应付单生成后 A302-6 指定应付)。方案细节见《2026-10-31-先票后款改造方案V2(收票登记A303-2+付款结算单A102-2+A302-6指定应付·实现流程与逻辑)》。
### 5.1 架构现状(★10-31 先票后款改造后终态)
```
【数据面】InvoiceReceiptKingdeeSyncTask 定时(默认关,每30分钟)
A801-1 全票池增量同步(incrementDays=30)→ invoiceTypes 白名单下推远端逐票种切片轮询
→ 2.5 万窗口硬上限触顶二分时间片递归 → 镜像 blade_kingdee_invoice_pool 批量 upsert(无变化跳写)
→ 2.5 万窗口硬上限触顶二分时间片递归 → 镜像 blade_kingdee_invoice_pool 批量 upsert(含 items_json 明细,无变化跳写)
→ refreshBoundReceipts:未绑定存量登记补绑(已绑定零写入)
手动端点:POST /invoice-receipt/sync-invoice-pool(同一方法+同一把锁)
【选票面】收票登记表单 / 结算单认领弹窗(KingdeeInvoicePoolDialog)
GET /invoice-receipt/invoice-pool、GET /formal-settlement/invoice-pool
→ 本地镜像真分页;仅「票号精确查 + 无认领/登记过滤 + 本地空页」时窄查 A801-1 回源一次再本地重查
【选票面】
GET /invoice-receipt/invoice-pool 收票登记选票弹窗:本地镜像真分页;仅「票号精确查+无过滤+本地空页」窄查 A801-1 回源
GET /invoice-receipt/invoice-detail-query ★新增 选票后带出发票明细行(镜像 items_json 优先,缺则按票号回源,仍无走票池窄查)
GET /invoice-receipt/claim-candidates ★新增 付款结算单「选择收票单」候选:审批通过+未被有效应付结算单认领,keyword 模糊,limit 200
行过滤:票号非空 + amountUseStatus=0 + availableAmount>0
【闸门】收票登记保存 persistMirrorFromRemote(按票号精确单查+可用性复核+FOR UPDATE upsert)
结算单认领确认 assertInvoicesClaimable(★10-05 补上:逐票窄查+可用复核,红冲/作废/被占用在此拦截)
【先票——收票登记】(不关联结算单/合同)
选票 → 表单带出票面+明细 → 手选结算组织(承办部门)→ 保存草稿(销方供应商代码尽力回填 queryCustomerCode)
→ 提交审批 → 审批通过(receipt 开关自动 / 工具栏「批量同步」手动)
→ 前置校验:审批通过 + assigned 不重推 + 已绑镜像(checkStatus=2)+ 镜像明细齐备(缺则回源)+ 结算组织必选
(★删除原 A102-4 应付预检与「恰好分摊 1 张应付结算单」要求)
→ A303-2 收票单保存 → 终态【pushed】→ A902 附件联带上传;缺票种编码按票号窄查 A801-1 补填
【推送链】审批通过自动推送(receipt 开关)/ 工具栏「批量同步」
A102-4 预检(应付已生成)→ A801-3 幂等预检(防重键=收票登记 id)
→ A303-2 收票单保存(付款方式固定现购)→ pushed → A302-6 指定应付 → assigned(终态)
→ A902 附件联带上传;缺票种编码按票号窄查 A801-1 补填
【后款——付款结算单(正式结算单应付 Tab)】
路径A 编辑器/新增:发票信息「选择收票单」(claim-candidates)→ 全额认领(匹配金额=发票全额,
★放开「发票匹配合计≤结算金额」上限校验)
路径B 审批后:列表行内「发票登记」→ 选收票单保存认领 → 弹窗「是否将发票匹配数据推送金蝶?」→ 确认调 A302-6
关系同步:rebuildInvoices / claimInvoices → syncSettlementReceiptRelations
(按发票号匹配审批通过收票登记 + 一票一结算单校验 + 重建 InvoiceReceiptSettlement + 刷新结算单收票状态)
结算单审批通过 → A102-2 应付单推送成功 → assignClaimedReceipts:本单认领且收票单 pushed 的逐单 A302-6 → assigned(终态)
(未推送/已指定跳过;单笔失败仅记日志不阻断结算同步)
手动端点:POST /formal-settlement/invoice-assign-kingdee(前置 payable+approved+synced)
【回写面】PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult(默认关,每10分钟)/ 手动端点
A801-3 按「收票登记 id」匹配(★缺陷已修)→ 发票状态异常回收置 failed
/ pushed 补偿重发 A302-6 / assigned 缺单号补登;窗口内查不到不改状态
【回写面】PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult(独立开关,每10分钟)/ 手动端点
A801-3 按「收票登记 id」匹配 → 发票状态异常(作废2/红冲3/部分红冲7/全部红冲8)回收置 failed
→ pushed+已认领(应付防重键恰好可组装)→ 补发 A302-6 → assigned(★指定补偿升级为重试引擎)
→ pushed+未认领 → 跳过不再置 failed(认领后由结算单侧补指定)
→ assigned 缺单号补登;窗口内查不到不改状态
【附件面】GET /invoice-receipt/invoice-pool/attachment/download?invoiceNo=(A801-2 流式代理)
镜像优先、实时回源兜底;pdfurl 优先 taxPdfUrl 兜底,都缺退 downloadUrl(fileType=4 判 OFD)
@@ -348,78 +398,114 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
| 页面 | 操作 | 后端端点 | 说明 |
|---|---|---|---|
| 收票登记(`/payment/invoice-receipt`) | 工具栏「批量同步」 | `POST /invoice-receipt/push-kingdee`(ids) | 单笔失败不中断整批,逐单报因 |
| 收票登记表单 | 发票号码放大镜(选票弹窗) | `GET /invoice-receipt/invoice-pool` | 镜像分页 + 回源;确认/保存前防重校验 |
| 收票登记表单 | 附件 查看/下载 | `GET /invoice-receipt/invoice-receipt/invoice-pool/attachment/download`(A801-2 代理) | 120s blob;PDF 优先 |
| 收票登记表单 | 发票号码放大镜(选票弹窗) | `GET /invoice-receipt/invoice-pool` | 镜像分页 + 回源;保存前防重校验 |
| 收票登记表单 | 选票后自动带出「发票明细」区块 | `GET /invoice-receipt/invoice-detail-query?invoiceNo=` | A303-2 分录取数预览(货物名称/数量/单价/金额/税率/商品编码) |
| 收票登记表单 | 手选「结算组织(合同承办部门)」必填 | 部门树选择 | 推送时沿部门树上溯平台公司取金蝶组织编码 |
| 收票登记表单 | 「结算信息」区 | 无调用(只读) | 展示认领关系:「暂无认领数据(认领关系由付款结算单侧维护)」 |
| 收票登记 | 行内「审批通过」 | `POST /invoice-receipt/approve` | receipt 开关开启时自动推送 |
| 正式结算单编辑器 | 发票认领区「查询可认领发票」/「确认识领」 | `GET /formal-settlement/invoice-pool`、`POST /formal-settlement/claim-invoices` | 确认时走认领闸门;未保存单仅本地暂存 |
| 共享弹窗 `KingdeeInvoicePoolDialog` | 行附件 查看/下载 | 同 A801-2 代理 | 票池弹窗、结算认领行、付款申请发票附件行、收票附件表 4 处复用 |
| 收票登记 | 附件 查看/下载 | `GET /invoice-receipt/invoice-pool/attachment/download`(A801-2 代理) | 120s blob;PDF 优先 |
| 付款结算单编辑器(应付) | 发票信息「选择收票单」 | `GET /invoice-receipt/claim-candidates` + `POST /formal-settlement/claim-invoices` | 候选=审批通过+未认领收票登记;**全额认领**(匹配金额=发票金额,不校验合计上限);保存同步收票侧关系 |
| 付款结算单列表 | 行内「发票登记」(审批通过行) | `POST /formal-settlement/claim-invoices` + `POST /formal-settlement/invoice-assign-kingdee` | 认领收票单 → 弹窗确认推送指定应付 |
| 共享弹窗 `KingdeeInvoicePoolDialog` | 行附件 查看/下载 | 同 A801-2 代理 | 票池弹窗、付款申请发票附件行、收票附件表等复用 |
### 5.3 后端要点
![收票登记表单(先票后款改造后):发票号码从金蝶票据池选票带出票面;新增「结算组织(合同承办部门)」手选必填;「发票明细」区块按票带出发票明细行(A303-2 分录取数来源);「结算信息」区只读——认领关系由付款结算单侧维护](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/tms-先票后款-收票登记表单.png)
**金蝶侧效果**:
![付款结算单「发票登记」弹窗(先票后款路径B):结算基本信息(结算单号/结算总金额/已匹配发票金额/金蝶同步状态)+ 认领收票单(选择收票单 → 发票号/开票单位(销方)/发票金额/匹配结算单金额)→ 保存认领后可推送指定应付](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/tms-先票后款-付款结算单发票登记.png)
### 5.3 后端要点(当前口径)
**A303-2 主表取值(★先票后款重写)**:
| 字段 | 取值 |
|---|---|
| awrp_src_code | 收票登记 id(重推幂等) |
| receivablessupp_number 往来户 | 登记回填的 `supplier_code`(销方名称→本地客商档案匹配,`customer_code` 兜底);缺失推送时实时再查一次,仍缺失**报错阻断**。供应商编码由 A901-1 在客商建档/同步复查时落库 |
| org_number 结算组织 | 页面手选部门 `kingdeeOrgNumberByDept`(沿部门树上溯唯一平台公司取金蝶组织编码) |
| currency_number 结算币别 | 固定 **CNY**(不挂合同后的默认,外币票暂无取值来源) |
| asstacttype 往来类型 | 固定 `bd_customer` |
| invoiceno/receivedate/issuedate | 登记发票号 / 推送当日 / 镜像开票日期 |
| biztype | 固定 `PUR`(采购);销方=镜像开票单位、购方=受票单位;`isincludetax=true`;awrp_src_type=TMS收票登记 |
**A303-2 分录(逐行取镜像 `items_json`,明细缺失/解析失败拦截)**:
| 字段 | 取值 |
|---|---|
| e_pricetaxtotal | 明细金额+税额(合计为 0 拦截) |
| quantity / price | 明细数量(≤0 兜底 1)/ 明细单价(**统一 4 位小数 HALF_UP**,空按价税合计÷数量反算) |
| material_number / e_taxclass_number | 行商品编码 goodsCode(数电票行商品编码即税收分类编码;缺失拦截) |
| taxrateid_number | `V`+整数税率(13%/13/0.13→V13;**缺失或 0/免税行拦截**提示人工) |
| e_amount / e_tax | 明细金额 / 税额(空不送) |
| awrp_entry_code | **发票号码_seq**(分录候选键,重推幂等) |
![收票登记列表:批量同步/新增/导出;付款方(受票单位)/收款方(开票单位)列随新表单口径展示](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/tms-先票后款-收票登记列表.png)
**状态机与关键规则**:
- 状态机:`unsynced → pushed`(A303-2 成功,**收票推送终点**)→ `assigned`(仅由付款结算单侧 A302-6 成功后)→ failed(A303-2 失败 / A801-3 发票状态异常回收 / 指定失败回收 / 自动路径前置拦截)。
- 关系同步 `syncSettlementReceiptRelations`:按行发票号匹配审批通过收票登记 → **一票一结算单校验**(已挂其他有效应付结算单抛「一张收票单只能被一张付款结算单认领」)→ 删旧建新(认领金额=发票全额)→ 刷新结算单收票状态。
- **收票状态按覆盖判定**:结算单已匹配发票金额 `>=` 结算金额即 completed(允许超额覆盖),否则 partial。
- **反向操作不同步金蝶**:结算单作废/认领移除不调 A302-6 解除指定,需金蝶侧人工解除后重推(已拍板)。
- 历史兼容:历史已 assigned 单据不受影响;收票登记编辑未传 settlements 时不动既有分摊关系(关系只由结算单侧维护)。
- 镜像比对集含 `itemsJson`(先票后款后明细是分录唯一取数来源,明细变化必须跟随票池刷新);全票池同步细节不变(2.5 万上限、单调二分、轻量比对跳写)。
**金蝶侧效果(历史推送数据,供对照)**:
![TMS 收票管理列表「金蝶状态」列:已指定应付(终态)/推送失败(带失败原因)/未推送,金蝶单据号 AVT-202608-000396 悬浮展示](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-收票金蝶状态列表.png)
![A902 附件上传金蝶侧效果:金蝶财务应付单详情「附件」区收到 TMS 推送单据联带上传的文件;付款计划区同时可见下推生成的付款计划(外部系统来源标识=防重键)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-附件上传金蝶侧.png)
![A801-2 发票查看/下载入口:正式结算单详情「发票信息」区认领的发票行提供 查看/下载(走后端 A801-2 流式代理,PDF 版式优先);付款信息区同时回显付款申请的金蝶单号](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png)
- 推送计划校验:approved、状态≠assigned、已绑定镜像、`checkStatus=2`、**恰好分摊 1 张审批通过应付结算单(一票一应付)**、有结算明细;不满足写 `blockedReason` 不改状态。
- A801-3 预检命中且状态正常→跳过 A303-2 直接续推指定(断点恢复);发票状态异常(作废2/红冲3/部分红冲7/全部红冲8)→failed 报人工。
- 落库:`kingdee_received_invoice_no`、状态机 `unsynced→pushed→assigned/failed` →`kingdee_push_status`(唯一金蝶状态来源);终态/失败写 `blade_invoice_receipt_record`;pushed/assigned 禁止本地作废。
- 全票池同步细节:单查询窗口 MAX_POOL_PAGES×500=2.5 万上限,`updateTime` 单调二分不重不漏;镜像 upsert 轻量比对(流水号/价税合计/税额/可用金额/使用状态)无变化跳写。
![A801-2 发票查看/下载入口:结算单详情「发票信息」区认领的发票行提供 查看/下载(走后端 A801-2 流式代理,PDF 版式优先);付款信息区同时回显付款申请的金蝶单号](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png)
---
## 6. 业务域五:收款认领(A301-2 / A301-3 / A301-1)
## 6. 业务域五:收款流水与认领(A301-1 数据源 + 合同合规推送)
**定位**:业务先行认领。金蝶司库第一轮自动认领到客户维度 → TMS 二次认领到结算单维度 → **本地认领完成后**(定时或手动)回推金蝶生成收款单 → 手动查询回写结果。★09-28 重构后:认领与推送解耦,推送按流水归集、定时+手动双入口。
> ★10-29 改造:数据源 A301-2 → **A301-1 收款单查询**,A301-3 推送金蝶链路整体下线,认领只登记本地并推送**合同合规系统**。金蝶收款单在认领前已存在(A301-1 即金蝶收款处理单),因此**不再向金蝶回写任何数据**。方案与操作细节见《2026-10-29-收款流水菜单改造(数据源A301-2改A301-1-认领只登记本地并推送合规C107-1与C107-3作废同步)》《2026-10-29-收款流水认领操作流程(收款金额本地额度·单条批量与作废)》。
### 6.1 链路总览
```
阶段一 收款流水页「流水同步」(手动)
POST /receipt-flow/sync ▶ A301-2 认领公告查询(claimusers=Nacos thirdParty.bfi.claimAnnounce.claimusers,
★不再按登录用户姓名;未配置则以空值调用会查不到数据)
▶ 全量翻页(窗口 months=6)▶ 按 detail_serial_no 幂等 upsert blade_kingdee_receipt_flow
阶段一 收款流水页「手动同步流水」(手动,无定时任务)
POST /receipt-flow/sync ▶ A301-1 queryRecBill
▶ 业务日期窗口=当天倒推 thirdParty.bfi.recBillSync.days(默认 60 天,支持热更新)
▶ 按平台公司金蝶组织编码过滤,全量翻页(pageSize 200,窗口切片规避单查 1 个月上限)
▶ 一张金蝶收款单=一条流水(幂等键 detail_serial_no=收款单号),金蝶单号/单据状态随同步刷新
▶ 前置:组织管理维护「是否平台公司=是」+ 金蝶组织编码,缺失直接报错拦截
阶段二 流水详情/批量认领(手动)
POST /receipt-flow/claim ▶ 本地落库(claim + 分摊 + 流水 claimed_amount/claim_status 刷新 + 结算单收款汇总)
▶ 本地完成即结束,**不调金蝶**(认领本地化★)
阶段二 流水详情/批量认领(手动,只登记本地)
POST /receipt-flow/claim ▶ 本地落库(认领主单+结算单分摊+流水 claimed_amount/claim_status 刷新+结算单收款汇总)
▶ 认领上限=收款金额 actrecamt(unmatchamountrec 未匹配金额仅参考展示,不参与额度控制)
▶ 不调金蝶(A301-3 下线★)
阶段三 推送金蝶(定时 ReceiptClaimPushTask 默认关每10分钟 / 手动流水行内「推送金蝶」)
pushClaimToKingdee ▶ 按认领人逐人重查 A301-2 → 扫「认领完成未推送」流水按流水归集
▶ 逐流水:awrp_src_code=分摊结算单号拼接(重试复用幂等)→ A301-3 推送
(分录单条:e_receivableamt=全部未推送金额;e_corebillno=第一张结算单金蝶内码,缺内码拦截不推)
▶ 成功回写 kingdee_bill_no(回执 keys.awrp_src_code 占位)+ kingdee_bill_status=pushed
阶段三 认领事务提交后推合同合规(afterCommit,失败只落标记不回滚)
HcsClaimSyncSupport.pushClaimToHcs ▶ C104-2 按明细流水号定位合规侧流水 ID
▶ 逐合同:C103-9 履约计划金额累加 → C107-1 认领记录(返回 fdId 落库 blade_receipt_claim_hcs_record)→ C108-1 履约流水
▶ 开关:exe-stage.sync-enabled 门禁 C103-9/C107-1;perf-record.sync-enabled 门禁 C108-1
阶段四 认领记录页「查询金蝶认领结果」(手动)
POST /receipt-claim-record/query-kingdee-result ▶ A301-1 收款单查询(今天回溯 30 天)
▶ sourcebillnumber==awrp_src_code 匹配 ▶ 真实收款单号覆盖占位单号 + 状态推进(C→approved,D→received 终态)
阶段四 作废(认领记录页,仅本人+已认领记录)
POST /receipt-claim-record/void ▶ 本地回退金额/状态 → afterCommit 逐条推 C107-3(fdStatus=1 已作废,按落库 fdId)
▶ 单条失败只记日志不回滚作废;无合规记录(曾推送失败)静默跳过;作废不生成金蝶冲销单
```
### 6.2 前端入口
| 页面 | 操作 | 后端端点 | 说明 |
|---|---|---|---|
| 收款流水(`/payment/receipt-flow`) | 工具栏「流水同步」 | `POST /receipt-flow/sync`(A301-2) | 权限 `receipt_flow_sync` |
| 同上 | 流水详情/批量认领表单「确认」 | `POST /receipt-flow/claim` | 选择审批通过应收结算单按比例分摊;已推送金蝶的认领禁止作废(后端硬校验) |
| 同上 | 行内「推送金蝶」 | `POST /receipt-flow/push-kingdee?flowId=` | 确认弹窗后按流水归集推送 |
| 认领记录(`/payment/receipt-claim-record`) | 工具栏「查询金蝶认领结果」 | `POST /receipt-claim-record/query-kingdee-result`(A301-1) | 手动;窗口内查不到不改状态 |
| 收款流水(`/payment/receipt-flow`) | 工具栏「手动同步流水」 | `POST /receipt-flow/sync`(A301-1) | 权限 `receipt_flow_sync`;前置组织金蝶编码 |
| 同上 | 流水详情/批量认领「确认」 | `POST /receipt-flow/claim` | 应收+审批通过+有剩余可认领金额的结算单;多选须同项目/组织/收付款方;批量认领须同付款人+对方账号 |
| 认领记录(`/payment/receipt-claim-record`) | 行内「作废」 | `POST /receipt-claim-record/void` | 仅本人+已认领;确认提示含「同步作废合同合规系统认领记录」 |
![收款流水列表(A301-1 数据源):收款单号即金蝶收款单号(RV-…)、收款金额为认领上限、未匹配金额仅参考;工具栏仅「手动同步流水/批量认领」——原「推送金蝶」按钮与推送状态搜索已随 A301-3 下线删除](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/tms-a301-1-收款流水列表.png)
![认领记录列表:原「查询金蝶认领结果」按钮已删除(金蝶状态随 A301-1 同步刷新,无需回写);作废操作同步作废合同合规系统认领记录](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/tms-认领记录页.png)
### 6.3 后端要点
**金蝶侧效果**:
![A301-2 对照:金蝶「认领公告列表」(RLTZ 通知单,待认领/部分认领)与 TMS 收款流水「手动同步流水」拉取的同批公告对照](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-2-公告同步对照.png)
![A301-3 对照:TMS 认领记录(RLTZ-20260612-0073 认领 828.00)推送后,金蝶「认领处理列表」生成认领单 RL-20260929-0058(已确认,认领通知单 0073 对应)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-认领推送对照.png)
![A301-3 金蝶侧认领结果详情:收款认领结果行「核心单据编号」=结算单星瀚内码(2586341365516352512,即 e_corebillno 传金蝶内码的落地证据)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-金蝶认领结果.png)
- 推送闸门:已推送(pushed)流水跳过不重推;`billid`(认领公告 id)缺失拦截「请先同步金蝶收款流水」;结算单金蝶内码缺失拦截不推。
- 幂等:`awrp_src_code` 重试复用同一编码;作废认领不再生成本地冲单(金蝶单据状态保留原值)。
- A301-3 字段待金蝶确认项仍在:`recpaytype_number`(常量当前值 "999",注释示例 101 待确认)、`paymenttype`、`recpayer`(金蝶付款人 master id)。
- **字段映射与表结构**:`blade_kingdee_receipt_flow` 新增 `org_number/open_org_number`(收款人/核算组织编码)、`match_amount_rec/match_amount_pay/unmatch_amount_pay/settle_amount/unsettle_amount`;`unclaimed_amount` 改名 `unmatch_amount_rec`;`bill_id/payer_id/kingdee_claim_status/claim_me/push_status/push_fail_reason/pushed_time/awrp_src_code` 等 A301-2/A301-3 专属列废弃保留(存量仅展示)。
- **单据状态映射(随同步刷新)**:A 暂存/B 已提交→pushed、C 已审核→approved、D 已收款→received、G 已退单/E 变更中/其它→failed(需人工在金蝶核查)。
- **认领额度**:流水维度「累计认领 ≤ 收款金额」;结算单维度「累计认领 ≤ 结算总应收含税金额」。金蝶侧已核销(未匹配金额=0)不影响认领。
- **合规推送失败不重试**:`blade_receipt_claim_hcs_record` 落 failed+原因,人工在合规侧补录(业务拍板,无定时补偿)。
- **下线清单**:`pushClaimToKingdee` 等推送方法、`ReceiptClaimPushTask`(10 分钟定时)、`/receipt-flow/push-kingdee`、`/receipt-claim-record/query-kingdee-result`、`awrpSrcCode` 生成、Nacos `claimAnnounce.*/receiptClaimPush.*` 配置全部删除。
---
@@ -459,20 +545,22 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
▼
定时 A302-4 勾稽纠偏 + A303-5 开票结果 ─▶ invoiced(终态,发票文件落库)
【收票主线】 定时 A801-1 全票池同步 ─▶ 镜像表 ◀── 选票读镜像(票号未命中窄查回源)
│ 收票登记保存(闸门)/ 结算认领确认(闸门★新增)
【收票主线·先票后款】 定时 A801-1 全票池同步(含明细 items_json)─▶ 镜像表 ◀── 选票/明细/候选读镜像(票号未命中窄查回源)
│ 收票登记保存(闸门);付款结算单认领收票单(syncSettlementReceiptRelations 关系同步)
▼
收票登记审批 ─A102-4 预检─A801-3 预检(登记id)─▶ A303-2 保存 ─▶ pushed ─▶ A302-6 ─▶ assigned
│(本期不锁单;A902 附件联带上传)
收票登记审批 ─A801-3 预检(登记id)─▶ A303-2 保存(销方供应商代码/CNY/明细行)─▶ pushed(推送终点)
│(本期不锁单;A902 附件联带上传;★不再 A102-4 预检、不再推送时立即 A302-6)
▼
定时 A801-3 回写(异常回收/指定补偿/单号补登,按登记 id 匹配★已修)
付款结算单 A102-2 应付单推送成功 ─▶ 逐单 A302-6 指定应付(手动端点/发票登记弹窗确认)─▶ assigned(终态)
▼
定时 A801-3 回写(异常回收/pushed+已认领补指定/单号补登,按登记 id 匹配)
【收款认领主线】 手动 A301-2 公告同步 ─▶ blade_kingdee_receipt_flow
│ 页面认领(本地化,不调金蝶)
【收款流水与认领主线】 手动 A301-1 收款单同步 ─▶ blade_kingdee_receipt_flow(金蝶单号/状态随同步刷新)
│ 页面认领(只登记本地,上限=收款金额;不调金蝶)
▼
定时/手动 A301-3 按流水归集推送(e_corebillno=结算单金蝶内码)─▶ pushed
认领事务提交后推合同合规:C103-9 → C107-1(fdId 落库)→ C108-1
▼
手动 A301-1 收款单查询 ─▶ 真实收款单号覆盖 + approved/received(终)
作废:本地回退 + C107-3(fdStatus=1);金蝶侧不回写
【横切】 审批通过 AFTER_COMMIT 自动同步(4 开关)│ 推送成功→lockBusiData(3 链路)
金蝶删单→unLockNotifyBusiData 回调还原 │ 锁失败定时/手动补偿
@@ -485,50 +573,53 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
|---|---|---|
| 1 | 客商档案先于一切推送(本地 customer_code 必须存在) | 查不到直接报错;金蝶侧审核状态不校验(软依赖) |
| 2 | 客商提审先过 A801-5 黑名单 | A 类命中/查询异常即阻断提审 |
| 3 | A102-2 先于 A103-1 与 A303-2 | A102-4 硬校验/预检 |
| 4 | A302-2 先于 A303-1 与 A301-3(e_corebillno 需金蝶内码) | A302-4 硬校验;内码缺失拦截不推 |
| 5 | A303-2 先于 A302-6 | 同一次推送内串行 |
| 6 | 推送先于回写(A103-2/A103-4、A302-4 勾稽、A303-5、A801-3、A301-1) | 查询键由推送链写入本地 |
| 3 | A102-2 先于 A103-1 | A102-4 硬校验/预检 |
| 4 | A302-2 先于 A303-1 | A302-4 硬校验 |
| 5 | A303-2(收票单)先于 A302-6(指定应付);A102-2(应付单)先于 A302-6 | 收票推送终点 pushed;A302-6 由付款结算单侧执行(应付推送成功后逐单指定 / 发票登记弹窗确认 / 手动端点 / A801-3 定时补偿) |
| 6 | 推送先于回写(A103-2/A103-4、A302-4 勾稽、A303-5、A801-3) | 查询键由推送链写入本地 |
| 7 | 锁单在推送成功之后、金蝶删单回调可还原 | lockAfterPush 事务提交后执行;unLockNotifyBusiData 免登回调 |
| 8 | A801-4 汇率查询先于 A102-2/A302-2(如需携带汇率) | 页面联动维护,未维护不传 |
| 9 | 客商 A901-1 供应商编码先于收票 A303-2 推送(销方供应商代码) | 销方名称匹配本地客商档案 supplier_code;缺失推送前强校验拦截 |
**断链恢复语义**:本地失败/远端未落单→unsynced 或 failed 可重推;远端成功本地回滚→重推时预检幂等回填(A102-4/A302-4/A103-2/A303-4/A801-3/awrp_src_code 复用);推送成功后状态滞后→定时回写;批量语义→整批预热+逐单独立事务+单条失败不中断。
---
## 9. 状态机汇总(2026-10-14)
## 9. 状态机汇总(2026-10-31)
| 表.字段 | 状态集 | 迁移 | 写入方 |
|---|---|---|---|
| `blade_formal_settlement.kingdee_sync_status` | unsynced / synced / **failed**(★新增写入口) | unsynced→(A102-2/A302-2 成功)→synced;推送异常→failed(独立事务);failed 可手动/定时重推、A302-4 勾稽纠偏回 synced;金蝶删单回调→voided + unsynced 还原 | FormalSettlementServiceImpl |
| `blade_payment_application.kingdee_status` | unsynced / synced / paying / paid(终) / closed(终) / failed | 同前版映射;failed 可纠偏回 synced;**连续 2 轮查不到→消失解锁置 failed**;金蝶删单回调→failed+清单号(不作废源单) | PaymentApplicationServiceImpl + 定时 |
| `blade_invoice_application.kingdee_status` | unsynced / synced / invoiced(终) / **failed**(★新增写入口) | unsynced→(A303-1 全部张次成功)→synced→(A303-5 新发票)→invoiced;推送异常→failed;金蝶删单回调→作废还原 | InvoiceApplicationServiceImpl + 定时 |
| `blade_invoice_receipt.kingdee_push_status` | unsynced / pushed / assigned(终) / failed | 同前版;A801-3 回写匹配键已修复为登记 id(异常回收/补偿/补登生效) | InvoiceReceiptServiceImpl + 定时 |
| `blade_customer_archive.kingdee_status` | none / synced / failed / exist(终) | 同前版;提审新增 A801-5 黑名单拦截(不产生状态) | CustomerArchiveServiceImpl + 定时 |
| `blade_invoice_receipt.kingdee_push_status` | unsynced / pushed / assigned(终) / failed | **★先票后款语义变更**:unsynced→(A303-2 成功)→pushed(**收票推送终点**);pushed→(付款结算单侧 A302-6 成功:应付推送后逐单指定/发票登记确认/手动端点/A801-3 指定补偿)→assigned;failed(A303-2 失败 / A801-3 发票状态异常回收 / 指定失败回收 / 前置拦截);pushed/assigned 禁止本地作废 | InvoiceReceiptServiceImpl + FormalSettlementServiceImpl + 定时 |
| `blade_customer_archive.kingdee_status` | none / synced / failed / exist(终) | 同前版;提审新增 A801-5 黑名单拦截(不产生状态);新增 `supplier_code` 字段(A901-1 查询回填,不产生状态) | CustomerArchiveServiceImpl + 定时 |
| `blade_kingdee_bill_lock.lock_status` ★新增 | failed / locked / unlocked | 先落 failed 占位→lockBusiData 成功→locked;补偿重试(try_count<5)或金蝶解锁/删单回调→unlocked | KingdeeBillLockServiceImpl + 定时 + 手动端点 |
| `blade_receipt_claim.kingdee_bill_status` | none / pushed / approved / received(终) / failed / voided | none→(A301-3)→pushed→(A301-1: C)→approved→(D)→received;已推送禁止作废 | ReceiptFlowServiceImpl + ReceiptClaimRecordServiceImpl |
| `blade_kingdee_receipt_flow.claim_status` | unclaimed / partial / claimed | 认领分摊→partial/claimed;全部作废→unclaimed;公告同步不覆盖 | ReceiptFlowServiceImpl |
| `blade_receipt_claim.kingdee_bill_no/kingdee_bill_status` | pushed / approved / received / failed | **★语义变更**:认领时从流水冗余快照(A301-1 同步值),认领不再推送金蝶、状态不再推进,仅随流水同步反映金蝶侧进展;作废→本地回退+推 C107-3 | ReceiptFlowServiceImpl + ReceiptClaimRecordServiceImpl |
| `blade_kingdee_receipt_flow.claim_status` | unclaimed / partial / claimed | 认领分摊→partial/claimed(上限=收款金额);作废回退重算;A301-1 同步不覆盖本地认领字段 | ReceiptFlowServiceImpl |
| `blade_kingdee_receipt_flow.kingdee_bill_status` | pushed / approved / received / failed | 随 A301-1 手动同步刷新:A/B→pushed、C→approved、D→received、G 退单/E 变更中/其它→failed | ReceiptFlowServiceImpl(sync) |
| `blade_receipt_claim_hcs_record.push_status` ★新增 | success / failed / voided | 认领事务提交后推 C107-1:成功落合规 fdId;失败落标记(无自动重试);作废推 C107-3 成功后置 voided | HcsClaimSyncSupport |
---
## 10. 定时任务与配置开关汇总
全部在 `blade-transport/task/`,Spring `@Scheduled`,统一 single-flight(`KingdeeSyncLockSupport`,`blade.lock.enabled=false` 降级直跑)。
全部在 `blade-transport/task/`,Spring `@Scheduled`,统一 single-flight(`KingdeeSyncLockSupport`,`blade.lock.enabled=false` 降级直跑)。**收款认领推送任务 `ReceiptClaimPushTask` 已随 A301-3 下线删除(10-29)。**
| 任务类 | 方法 | 覆盖业务(接口) | cron 键(默认) | 开关键(代码默认) | 手动端点 |
|---|---|---|---|---|---|
| PaymentKingdeeResultTask | syncKingdeePaymentResult | ① A103-2/A103-4 付款结果 ② A302-4 应收勾稽 ③ A303-5 开票结果 | `thirdParty.bfi.payResult.cron`(`0 */10 * * * ?`) | `thirdParty.bfi.payResult.syncEnabled`(false;**Nacos 样例 dev/prod 已显式 true**) | `/payment-application/sync-kingdee-result`、`/formal-settlement/sync-kingdee-ar-result`、`/invoice-application/sync-kingdee-invoice-result` |
| PaymentKingdeeResultTask | syncKingdeeReceivedInvoiceResult | A801-3 收票结果回写 | `thirdParty.bfi.receivedInvoiceResult.cron`(每10分钟) | `…receivedInvoiceResult.syncEnabled`(false) | `/invoice-receipt/sync-kingdee-result` |
| CustomerKingdeeSyncTask | syncKingdeeCustomerData | A902-1 复查 + A902-2 重推 + 金蝶停用联动 | `thirdParty.bfi.customer.cron`(`0 0 2 * * ?`) | `…customer.syncEnabled`(false;KS000067 残留已移除) | `/customer-archive/sync-kingdee` |
| **ReceiptClaimPushTask** ★新增 | pushClaimToKingdee | A301-3 认领推送(按流水归集,前置重查 A301-2) | `thirdParty.bfi.receiptClaimPush.cron`(每10分钟) | `…receiptClaimPush.syncEnabled`(false) | `/receipt-flow/push-kingdee` |
| **InvoiceReceiptKingdeeSyncTask** ★新增 | syncKingdeeInvoicePool | A801-1 全票池瘦身同步 + 绑定回填 | `thirdParty.bfi.invoiceReceipt.cron`(每30分钟) | `…invoiceReceipt.syncEnabled`(false);增量窗口 `…incrementDays`(30);票种白名单 `…invoiceTypes` | `/invoice-receipt/sync-invoice-pool` |
| **KingdeeAutoSyncRetryTask** ★新增 | retryKingdeeFailed | 4 域 failed 单据重推(结算/付款/开票/收票;不扫 unsynced;无次数上限,靠幂等预检防重) | `thirdParty.bfi.autoSync.retryCron`(每10分钟) | `…autoSync.retryEnabled`(false) | 各域 `/sync-kingdee` |
| **KingdeeBillLockRetryTask** ★新增 | retryFailedBillLocks | 锁单失败补偿(failed 且 try_count<5 重调 lockBusiData) | `thirdParty.bfi.billLock.retryCron`(每5分钟) | `…billLock.retryEnabled`(false) | `/formal-settlement/retry-kingdee-bill-lock` |
| PaymentKingdeeResultTask | syncKingdeePaymentResult | ① A103-2/A103-4 付款结果 ② A302-4 应收勾稽 ③ A303-5 开票结果 | `thirdParty.bfi.payResult.cron`(`0 1/10 * * * ?`) | `thirdParty.bfi.payResult.sync-enabled`(false;**Nacos 样例 dev/prod 已显式 true**) | `/payment-application/sync-kingdee-result`、`/formal-settlement/sync-kingdee-ar-result`、`/invoice-application/sync-kingdee-invoice-result` |
| PaymentKingdeeResultTask | syncKingdeeReceivedInvoiceResult | A801-3 收票结果回写(异常回收/**pushed+已认领补指定**/单号补登) | `thirdParty.bfi.receivedInvoiceResult.cron`(`0 8/10 * * * ?`,与付款回写独立调度) | `thirdParty.bfi.receivedInvoiceResult.sync-enabled`(false) | `/invoice-receipt/sync-kingdee-result` |
| CustomerKingdeeSyncTask | syncKingdeeCustomerData | A902-1 复查 + A902-2 重推 + 金蝶停用联动 | `thirdParty.bfi.customer.cron`(`0 0 2 * * ?`) | `thirdParty.bfi.customer.sync-enabled`(false) | `/customer-archive/sync-kingdee` |
| InvoiceReceiptKingdeeSyncTask | syncKingdeeInvoicePool | A801-1 全票池瘦身同步(含明细 items_json)+ 绑定回填 | `thirdParty.bfi.invoiceReceipt.cron`(`0 16/30 * * * ?`) | `thirdParty.bfi.invoiceReceipt.sync-enabled`(false);增量窗口 `…incrementDays`(30);票种白名单 `…invoiceTypes` | `/invoice-receipt/sync-invoice-pool` |
| KingdeeAutoSyncRetryTask | retryKingdeeFailed | 4 域 failed 单据重推(结算/付款/开票/收票;不扫 unsynced;无次数上限,靠幂等预检防重);收票重推不再依赖 A102-4 | `thirdParty.bfi.autoSync.retryCron`(`0 5/10 * * * ?`) | `thirdParty.bfi.autoSync.retry-enabled`(false) | 各域 `/sync-kingdee` |
| KingdeeBillLockRetryTask | retryFailedBillLocks | 锁单失败补偿(failed 且 try_count<5 重调 lockBusiData) | `thirdParty.bfi.billLock.retryCron`(`0 2/5 * * * ?`) | `thirdParty.bfi.billLock.retry-enabled`(false) | `/formal-settlement/retry-kingdee-bill-lock` |
**审批自动同步开关(监听器,非定时)**:`thirdParty.bfi.autoSync.settlement / payment / invoiceApply / receipt`(默认全 false)。
**其他业务键**:`thirdParty.bfi.claimAnnounce.claimusers`(A301-2 认领人,**必配**)、`…claimAnnounce.months`(6)、`thirdparty.bfi.blacklist.blockRiskWarning`(false)、`thirdparty.bfi.material.*`(物料映射)。
**其他业务键**:`thirdParty.bfi.recBillSync.days`(★新增,A301-1 同步窗口天数,默认 60)、`thirdparty.bfi.blacklist.blockRiskWarning`(false)、`thirdparty.bfi.material.*`(物料映射)。
**已删除配置**:`thirdParty.bfi.claimAnnounce.*`(A301-2 认领人/窗口)、`thirdParty.bfi.receiptClaimPush.*`(认领推送定时)——代码已不再读取,Nacos 中可清理。
> ⚠️ Nacos 样例(`doc/nacos/`)仅显式配置了 `payResult.syncEnabled=true`;`receiptClaimPush/invoiceReceipt` 样例仍为 false;`customer/receivedInvoiceResult/autoSync/billLock` 键未出现在样例中——生效值取决于各环境实际 Nacos,缺省走代码 false。**开启生产定时前逐项核对第 12 节风险清单。**
> ⚠️ Nacos 样例(`doc/nacos/`)仅显式配置了 `payResult.sync-enabled=true`;`customer/receivedInvoiceResult/autoSync/billLock/invoiceReceipt` 键未出现在样例中——生效值取决于各环境实际 Nacos,缺省走代码 false。**开启生产定时前逐项核对第 12 节风险清单。**
---
@@ -540,7 +631,7 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
|---|---|---|
| 1 | 部门金蝶组织编码 | 部门档案维护 `kingdee_dept_code`;未维护的部门推送组织取数会直接拦截 |
| 2 | 主数据 SQL | 执行 `doc/sql/transport/kingdee_master_data_init_20260924.sql`(计量单位档案等);认领链路执行 `blade_receipt_flow_claim_20260924.sql`;10-13 迁移脚本见 `doc/sql/transport/lqj/` |
| 3 | Nacos 业务配置 | `thirdParty.bfi.claimAnnounce.claimusers`(认领人,与金蝶公告 claimusers 一致)、`thirdParty.bfi.invoiceReceipt.invoiceTypes`(票池同步票种白名单)、`thirdparty.bfi.material.*`(物料映射,缺省有默认值但建议显式配置) |
| 3 | Nacos 业务配置 | `thirdParty.bfi.recBillSync.days`(A301-1 同步窗口天数,默认 60,可选)、`thirdParty.bfi.invoiceReceipt.invoiceTypes`(票池同步票种白名单)、`thirdparty.bfi.material.*`(物料映射,缺省有默认值但建议显式配置) |
| 4 | 定时开关按需开启 | 见第 10 节表:付款结果回写样例已开;其余按环境显式开启 `syncEnabled/retryEnabled` |
| 5 | 审批自动同步开关 | `thirdParty.bfi.autoSync.settlement/payment/invoiceApply/receipt` 按域开启;未开启时走页面「同步金蝶」手动推送 |
| 6 | 客商收款账户 | 维护 `blade_customer_receipt_account` 联行号(cnaps_code)——付款申请/预付款/客商推送均取真实联行号,缺失拦截 |
@@ -570,17 +661,22 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
→ 等 A303-5 定时回写发票结果(或手动端点)→ 状态 invoiced
→ 正式结算单详情「开票发票」区查看/下载发票文件
⑤ 收票(进项)
收票登记表单:发票号放大镜从金蝶票池选票(镜像读,票号未命中自动回源)→ 保存(重查闸门校验可用性)
→ 分摊到一张审批通过应付结算单 → 审批通过(autoSync.receipt 开启时自动推送;或「批量同步」)
→ A303-2 保存→pushed→A302-6 指定应付→assigned(终态)
→ 等 A801-3 定时回写(pushed 断点自动补偿重发 A302-6)
⑤ 收票(进项·先票后款两段式)
【先票】收票登记表单:发票号放大镜从金蝶票池选票(镜像读,票号未命中自动回源)→ 表单带出发票明细
→ 手选结算组织(合同承办部门)→ 保存(重查闸门校验可用性;销方供应商代码自动回填)
→ 提交审批 → 审批通过(autoSync.receipt 开启时自动推送;或「批量同步」)
→ A303-2 保存(往来户=销方供应商代码/币别 CNY/分录=发票明细行)→ pushed(收票推送终点)
⑥ 收款认领
收款流水页「流水同步」拉取金蝶认领公告 → 流水详情/批量认领:选审批通过应收结算单按比例分摊(本地落库)
→ 「推送金蝶」(行内手动,或等 ReceiptClaimPushTask 定时)→ 金蝶生成收款单
→ 认领记录页「查询金蝶认领结果」回写真实收款单号与状态(approved/received)
注意:已推送金蝶的认领不可作废,需金蝶侧人工处置
【后款】付款结算单(应付)编辑或「发票登记」:选择收票单(候选=审批通过+未认领)→ 全额认领
→ 结算单审批通过 → A102-2 应付单推送成功 → 逐单 A302-6 指定应付 → 收票单 assigned(终态)
→ 卡在 pushed 不用手动处理:被认领后由 A801-3 定时补偿自动补发指定
⑥ 收款流水与认领(认领本地化,不推金蝶)
收款流水页「手动同步流水」拉取金蝶收款单(A301-1,窗口默认 60 天;前置组织金蝶编码)
→ 流水详情/批量认领:选审批通过应收结算单填分摊金额(上限=收款金额,本地落库)
→ 认领事务提交后自动推合同合规(C103-9→C107-1→C108-1,失败只落标记)
→ 需撤销时认领记录页「作废」:本地回退金额 + 自动同步合规 C107-3
注意:金蝶侧认领/收款处理由财务在金蝶直接操作,TMS 只做本地认领登记与合规同步
⑦ 查看附件
金蝶票池/认领发票/收票附件的「查看/下载」统一走后端 A801-2 代理(PDF 版式优先)
@@ -597,13 +693,19 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
| 付款单在金蝶被删且无回调 | A103-2 连续 2 轮(约 20 分钟)查不到 | 自动消失解锁置 failed,按 failed 重推 |
| 断链重推(远端已落单本地未同步) | 重推时预检命中 | 自动幂等回填单号跳过重复推送,无需人工清库 |
| 收票单发票状态异常 | 定时回写发现红冲/作废 → failed「已回收推送状态」 | 人工在金蝶侧处理后重新登记/推送 |
| 收票推送报「供应商代码/往来户」类错误 | 销方名称未匹配到客商档案 `supplier_code`(A901-1 未回填) | 先完成客商建档/金蝶同步(A901-1 回填供应商编码),或用 `GET /customer-archive/query-supplier-code` 核对后重推 |
| 收票推送报免税/零税率行拦截 | `taxrateid_number` 无法映射(0 税率/免税行) | 人工核实票面,与金蝶约定编码后放开(有意拦截) |
| 收票推送报结算组织/平台公司类错误 | 手选部门沿部门树上溯不到带金蝶组织编码的平台公司 | 管理员维护组织档案金蝶编码后重推 |
| 收票单卡在 pushed 未变 assigned | 未被付款结算单认领,或认领它的结算单应付单未推送成功 | 在付款结算单做「发票登记」/编辑认领收票单,并确认该结算单已 synced;A801-3 补偿只处理「已认领」的 pushed 单 |
| 认领收票单被拦「一张收票单只能被一张付款结算单认领」 | 该收票登记已被其他有效应付结算单认领 | 先在原结算单移除认领(金蝶侧人工解除指定)再认领 |
| 票池选不到票 | 镜像无数据 | 检查全票池同步开关与 `invoiceTypes` 白名单;或 `POST /invoice-receipt/sync-invoice-pool` 手动同步;票号精确查会自动回源 |
| 收款流水同步为空 | A301-2 查不到公告 | 检查 Nacos `claimAnnounce.claimusers` 是否与金蝶公告认领人一致 |
| 收款流水同步为空/报组织编码错误 | 组织管理未维护「平台公司」金蝶编码,或窗口内无数据 | 维护组织金蝶编码后重试;需要更早数据时调大 `thirdParty.bfi.recBillSync.days`(支持热更新) |
| 认领/作废合规推送失败 | C103-9/C107-1/C108-1/C107-3 返回异常 | 查 `blade_receipt_claim_hcs_record` 失败原因,人工在合规侧补录(无自动重试);作废主流程不受影响 |
| 定时任务不跑/重复跑 | — | 核对 Nacos 开关键名(第 10 节);single-flight 锁保证多副本/手动不并发,`blade.lock.enabled=false` 时无锁 |
---
## 12. 风险与待确认清单(2026-10-14)
## 12. 风险与待确认清单(2026-10-31)
### 12.1 已关闭(旧总纲遗留,当前代码已核实修复)
@@ -615,27 +717,35 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶)
| 客商推送 bankNumber 硬编码 / A103-3 e_bebank_number 取名称字段 | ✅ 均改取客商收款账户联行号 |
| 结算/开票失败不落 failed | ✅ 统一落 failed(03b34310) |
| 结算单认领无保存重查闸门 | ✅ 已实现 assertInvoicesClaimable |
| 收票推送依赖「恰好分摊 1 张应付结算单」+ A102-4 预检 | ✅ 已随先票后款改造删除(10-31,收票登记不再挂结算单) |
| 收款认领推送金蝶(A301-3 定时+手动) | ✅ 链路整体下线(10-29),认领只登记本地+推合同合规 |
### 12.2 仍需关注 / 联调确认
### 12.2 仍需关注 / 联调确认(2026-10-31)
| # | 事项 | 证据/说明 |
|---|---|---|
| 1 | **A103-1 勾稽键口径**:代码传本地组装的应付防重键,`PaymentApplicationServiceImpl:1431-1443` 注释仍写「A102-4 反查 awrp_entry_code_p、禁止自行拼接」——注释与代码矛盾,金蝶侧付款计划分录标识是否等于防重键需联调实测 | 高优先 |
| 2 | **项目编码 999 残留**:全部推送链路(应付/应收/付款/预付款/开票申请)仍传测试值 999;A103-3「取项目档案真实编码」的逻辑已注释停用(详见入参映射表 14-#2) | 上线前处理 |
| 3 | **计量单位不送**:分录 DTO 有字段无赋值——需与金蝶确认不送计量单位是否影响单据校验 | 联调确认 |
| 4 | **A301-3 字段待金蝶确认**:`recpaytype_number`(101)/`paymenttype`/`recpayer` 取值口径 | 联调确认 |
| 5 | **A301-2 claimusers 未配置风险**:缺省空串调 A301-2 查不到数据(无报错),需在 Nacos 必配 | 配置项 |
| 6 | **其他链路不校验客商 kingdee_status**:客商未推送/未审核也会带编码推单(软依赖) | 既有风险 |
| 7 | **正式结算单作废未接 checkEditable**:仅靠前端锁状态标签禁用,接口层可绕过 | 建议补拦截 |
| 8 | **failed 重推无次数上限**:`KingdeeAutoSyncRetryTask` 每 10 分钟扫全量 failed,持续失败的单据会反复调金蝶(靠预检防重但不封顶) | 观察线上量 |
| 9 | **付款申请前端单笔「同步金蝶」按钮未绑定**(`handleSyncRow` 死代码):单笔入口仅剩审批自动同步 | 前端待补 |
| 10 | **旅客运输/代收车船税单据体**:DTO 已建未接(仅 freights 接入) | 按需迭代 |
| 11 | **方案 vs 代码差异(沿旧总纲)**:付款多次下推未实现(单分录单次);附件已落地(A902 4 链路)✅;收款流水同步已有定时(receiptClaimPush)✅;黑名单 A801-5 已实现 ✅ | — |
| 1 | **A103-1 勾稽键口径**:代码传本地组装的应付防重键,`PaymentApplicationServiceImpl` 注释仍写「A102-4 反查 awrp_entry_code_p、禁止自行拼接」——注释与代码矛盾,金蝶侧付款计划分录标识是否等于防重键需联调实测 | 高优先 |
| 2 | **项目编码 999 残留**:应付/应收/付款/预付款/开票申请链路仍传测试值 999(收票 A303-2 无项目字段) | 上线前处理 |
| 3 | **应付/应收计量单位不送**:分录 DTO 有字段无赋值——需与金蝶确认;收票 A303-2 明细行送镜像数量/单价 | 联调确认 |
| 4 | **先票后款免税/零税率行**:`taxrateid_number` 无法映射,推送时报错人工核实(如业务出现需与金蝶约定编码后放开) | 有意拦截 |
| 5 | **先票后款币别固定 CNY**:收票登记不挂合同后 `currency_number` 固定 CNY,出现外币进项票需补充取值来源 | 上线前确认 |
| 6 | **A303-2 明细时点**:分录取镜像最新值,登记→推送间金蝶侧修正明细行会被跟随;红冲/作废由回写回收兜底 | 联调观察 |
| 7 | **收票反向操作不同步金蝶**:结算单作废/认领移除不调 A302-6 解除指定,需金蝶侧人工解除后重推(已拍板) | 操作约定 |
| 8 | **销方供应商代码依赖客商档案**:`queryCustomerCode` 只按销方名称匹配本地 `blade_customer_archive.supplier_code`(A901-1 建档/同步时落库),不实时调金蝶;客商档案缺数据会推送拦截 | 数据前提 |
| 9 | **A301-1 明细流水号同源性**:`bei_detailid` 须与原 A301-2 `detailid`、合规侧 `fdDetailSerialNumber` 同值,否则 C104-2 定位失败 → C107-1 跳过(需联调真实报文核对) | 高优先 |
| 10 | **合规履约计划金额不回退**:认领 C103-9 累加,作废仅同步 C107-3,不回退履约计划行金额(与改造前口径一致) | 既有口径 |
| 11 | **其他链路不校验客商 kingdee_status**:客商未推送/未审核也会带编码推单(软依赖) | 既有风险 |
| 12 | **正式结算单作废未接 checkEditable**:仅靠前端锁状态标签禁用,接口层可绕过 | 建议补拦截 |
| 13 | **failed 重推无次数上限**:`KingdeeAutoSyncRetryTask` 每 10 分钟扫全量 failed,持续失败的单据会反复调金蝶(靠预检防重但不封顶) | 观察线上量 |
| 14 | **付款申请前端单笔「同步金蝶」按钮未绑定**(`handleSyncRow` 死代码):单笔入口仅剩审批自动同步 | 前端待补 |
| 15 | **旅客运输/代收车船税单据体**:DTO 已建未接(仅 freights 接入) | 按需迭代 |
---
## 附:文档关系
- 本文为新总纲(基线 2026-10-13 `805ca9d3`,2026-10-15 增补),替代 09-27 版;单链路细节仍以 `doc/docs/业务流程/` 对应专项文档为深入参考,冲突处以本文 + 当前代码为准。
- 本文为新总纲(基线 2026-10-31 `933fb37d`,分支 feature/20261031-bfi-收票收款改造),替代 09-27 版;单链路细节仍以 `doc/docs/业务流程/` 对应专项文档为深入参考,冲突处以本文 + 当前代码为准。
- **各推送接口的逐字段入参取值映射(页面字段→表字段/配置对照)见《2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md》**,其第 14 节含与本文记载不一致的代码级核对结论。
- 09-27 版中被本文修订的关键结论:A801-3 回写缺陷、票池「实时回源无定时」架构、收款认领「全手动无定时」、分录调试覆盖、物料映射常量、failed 落库口径、A801-5/A801-2/A902「未实现」状态。
- **收票先票后款专项**:《2026-10-31-先票后款改造方案V2(收票登记A303-2+付款结算单A102-2+A302-6指定应付·实现流程与逻辑)》。
- **收款流水改造专项**:《2026-10-29-收款流水菜单改造(数据源A301-2改A301-1-认领只登记本地并推送合规C107-1与C107-3作废同步)》《2026-10-29-收款流水认领操作流程(收款金额本地额度·单条批量与作废)》《2026-10-16-收款流水多次认领与作废口径梳理》。
- 09-27/10-14 版中被本文修订的关键结论:**A301-2/A301-3 链路已下线(10-14 版第 6 节口径作废)**、收票推送终点 pushed + A302-6 后置(10-14 版「同一次推送内串行」作废)、A901-1/A905-3 已实现(10-14 版「未实现」作废)、HCS 付款履约计划推送时点改为金蝶实付后。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Some files were not shown because too many files have changed in this diff Show More