From 82b347cb18c76c00e19a9cae92295305af487eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 21 Sep 2026 19:14:05 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E5=BA=94=E6=94=B6=E6=AD=A3?= =?UTF-8?q?=E5=BC=8F=E7=BB=93=E7=AE=97=E5=8D=95=E5=90=8C=E6=AD=A5=E9=87=91?= =?UTF-8?q?=E8=9D=B6=E8=B4=A2=E5=8A=A1=E5=BA=94=E6=94=B6=E5=8D=95=EF=BC=88?= =?UTF-8?q?A302-2=E4=BF=9D=E5=AD=98+A302-4=E5=B9=82=E7=AD=89=E9=A2=84?= =?UTF-8?q?=E6=A3=80=E4=B8=8E=E5=AE=9A=E6=97=B6=E5=8B=BE=E7=A8=BD=E7=BA=A0?= =?UTF-8?q?=E5=81=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pojo/entity/FormalSettlement.java | 104 ++++++ .../transport/config/BfiProperties.java | 18 + .../constant/KingdeeBfiConstant.java | 24 ++ .../FormalSettlementController.java | 9 +- .../service/IFormalSettlementService.java | 1 + .../impl/FormalSettlementServiceImpl.java | 99 +++++- .../support/KingdeeApBillSyncSupport.java | 39 +-- .../support/KingdeeArBillSyncSupport.java | 316 ++++++++++++++++++ 8 files changed, 573 insertions(+), 37 deletions(-) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java 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); + } +}