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