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 4c147ec1..6de26b21 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 @@ -58,21 +58,12 @@ public final class KingdeeBfiConstant { /** * A102-2 付款方式(赊购 CREDIT,允许金蝶侧后续调整付款金额; - * 现购 CASH 会由金蝶自动 100% 推付款)。待确认:如业务要求现购再调整。 */ - public static final String BFI_AP_BILL_PUR_MODE = "CREDIT"; - + public static final String BFI_AP_BILL_PUR_MODE_CREDIT = "CREDIT"; /** - * A102-2 明细必填:物料编码。TMS 无物料档案,当前为占位空值, - * 推送前会校验拦截。待确认:需金蝶侧提供运输服务类物料编码后补充。 + * A102-2 付款方式 现购 CASH 会由金蝶自动 100% 推付款)。 */ - public static final String BFI_AP_BILL_MATERIAL = "GWZB01.003"; - - /** - * A102-2 明细必填:计量单位编码。TMS 无对应档案,当前为占位空值, - * 推送前会校验拦截。待确认:需金蝶侧确认编码(如"吨")后补充。 - */ - public static final String BFI_AP_BILL_MEASURE_UNIT = "PCS"; + public static final String BFI_AP_BILL_PUR_MODE_CASH = "CASH"; /** A102-2 必填:外部单据类型(应付单来源标识)。待确认:取值需金蝶侧确认,确认后如有调整直接改此常量。 */ public static final String BFI_AP_BILL_SRC_TYPE = "TMS财务应付单"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index b365bf72..f2398b48 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -31,9 +31,12 @@ import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.SpringUtil; +import org.springblade.core.tool.api.R; import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.system.cache.DictBizCache; import org.springblade.system.cache.SysCache; +import org.springblade.system.feign.ISysClient; +import org.springblade.system.pojo.entity.CargoType; import org.springblade.system.pojo.entity.DictBiz; import org.springblade.system.pojo.entity.Dept; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; @@ -51,6 +54,8 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper; +import org.springblade.transport.mapper.FormalSettlementDetailMapper; import org.springblade.transport.mapper.FormalSettlementMapper; import org.springblade.transport.mapper.FormalSettlementPaymentMapper; import org.springblade.transport.mapper.BillLedgerMapper; @@ -71,6 +76,8 @@ import org.springblade.transport.pojo.dto.PaymentApplicationRecordRequest; import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest; import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest; import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.FormalSettlementDetail; +import org.springblade.transport.pojo.entity.FormalSettlementDetailFee; import org.springblade.transport.pojo.entity.FormalSettlementPayment; import org.springblade.transport.pojo.entity.BillLedger; import org.springblade.transport.pojo.entity.BillLedgerUsage; @@ -100,6 +107,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -131,6 +139,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl entryGroups, List documentNos) { ContractManage contract = requireContract(entity); // 单据类型按映射字典转换:默认服务采购应付,金蝶确认其他类型后修改 BFI_AP_BILL_BILLTYPE_NAME 即可 String billtypeNumber = KingdeeBfiConstant.billtypeBy(KingdeeBfiConstant.AP_BILL_BILLTYPE_MAP, KingdeeBfiConstant.BFI_AP_BILL_BILLTYPE_NAME); @@ -778,7 +790,6 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl awrpSrcCodeParts = new ArrayList<>(); + awrpSrcCodeParts.add(entity.getPaymentNo()); + awrpSrcCodeParts.addAll(documentNos); + param.setAwrpSrcCode(String.join("_", awrpSrcCodeParts)); // 来源单据(非必传,有上游合同时传 awrp_contract_info) param.setAwrpSrcBt(KingdeeBfiConstant.BFI_CONTRACT_SRC_BT); // 上游外部系统单据号/上游合同号(非必传) @@ -826,22 +840,28 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl detailentry = new ArrayList<>(); + for (int index = 0; index < entryGroups.size(); index++) { + BfiEntryGroup entryGroup = entryGroups.get(index); + BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO(); + // 【必填】物料编码.编码:结算明细货物类型匹配货物类型档案货物编码 + entry.setMaterialNumber(entryGroup.materialNumber()); + // 【必填】计量单位.编码:货物费用行运费计算单位(快照 price_unit,档案暂无编码字段暂传单位名称) + entry.setMeasureunitNumber(entryGroup.measureunitNumber()); + // 【必填】数量:组内结算明细运输数量合计,无吨位数据时兜底 1 + entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); + // 应付金额(含税)(非必传):按组内结算金额占比分配的申请付款金额 + entry.setEPricetaxtotal(entryGroup.amount()); + // 【必填】项目.项目编码 + entry.setProjectNumber(bfiProjectNumber(entity)); + // 【必填】外部系统来源分录标识(候选键),A103-1 下推时以 awrp_finapbill_entryid 关联此行 + entry.setAwrpEntryCode(entity.getPaymentNo() + "-" + (index + 1)); + // 分录备注(非必传) + entry.setERemark(entity.getRemark()); + detailentry.add(entry); + } + param.setDetailentry(detailentry); // 付款计划 planentity 不传,由金蝶按明细自动汇总生成 // TODO 待业务确认:存在预付款抵扣时是否需传预付信息 preentry(y_billtype/y_billid/y_settleamt),本期不传、后续迭代 @@ -852,17 +872,18 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl entryGroups) { ContractManage contract = requireContract(entity); String orgNumber = bfiOrgNumber(contract); // TODO 待确认:日期格式沿用现有 A103-3 的 yyyyMMdd,若金蝶校验要求 yyyy-MM-dd 再统一调整 String applyDateText = (entity.getApplyDate() == null ? LocalDate.now() : entity.getApplyDate()).format(DateTimeFormatter.BASIC_ISO_DATE); - BigDecimal applyAmount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); BfiPayApplyPushDTO param = new BfiPayApplyPushDTO(); param.setAwrpSrcCode(entity.getPaymentNo()); param.setAwrpSrcSys(bfiPayApplySrcSys); @@ -875,26 +896,258 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl entries = new ArrayList<>(); + for (int index = 0; index < entryGroups.size(); index++) { + BfiEntryGroup entryGroup = entryGroups.get(index); + BfiPayApplyPushEntryDTO entry = new BfiPayApplyPushEntryDTO(); + // 正式付款固定 201 + entry.setEPaymenttypeNumber(KingdeeBfiConstant.BFI_PAYMENT_TYPE_FORMAL); + // 关联 A102-2 财务应付单分录,对应应付单明细的 awrp_entry_code + entry.setAwrpFinapbillEntryid(entity.getPaymentNo() + "-" + (index + 1)); + entry.setAwrpEntryCode(entity.getPaymentNo() + "-" + (index + 1)); + entry.setEAssacct(entity.getBankAccount()); + // TODO 待确认:往来银行为必填编码(e_bebank_number),当前暂传银行名称,待建立银行档案编码映射后调整 + entry.setEBebankNumber(entity.getBankName()); + // 申请金额:与对应应付单分录分配金额保持一致 + entry.setEApplyamount(entryGroup.amount()); + entry.setEExpaydate(applyDateText); + entry.setProjectNumber(bfiProjectNumber(entity)); + entry.setERemark(entity.getRemark()); + entry.setAwrpSrcType(bfiPayApplySrcType); + entries.add(entry); + } + param.setEntry(entries); BfiBillSaveResultVO saveResult = bfiService.pushPayApply(param); return requireBfiBillId(saveResult, "推送金蝶付款申请(应付下推)"); } + /** + * 财务应付单/付款申请分录组:结算明细按货物编码+计量单位聚合后的分录数据 + * + * @param materialNumber 物料编码(货物类型档案货物编码) + * @param measureunitNumber 计量单位编码(运费计算单位,档案暂无编码字段暂传单位名称) + * @param quantity 组内运输数量合计 + * @param baseAmount 组内结算金额(含税)合计,作为申请金额分配基数 + * @param amount 分配后的申请付款金额 + */ + private record BfiEntryGroup(String materialNumber, String measureunitNumber, BigDecimal quantity, + BigDecimal baseAmount, BigDecimal amount) { + } + + /** + * 应付单分录解析结果 + * + * @param entryGroups 结算明细聚合出的应付单分录组(已分配金额) + * @param documentNos 结算明细单据号(去重、保持行序),用于拼接外部系统来源单据号 + */ + private record BfiEntryGroupResult(List entryGroups, List documentNos) { + } + + /** + * 解析付款申请关联的正式结算明细,按货物编码+计量单位聚合为应付单分录组。 + *

物料编码:货物费用行/结算明细货物类型(支持“、”分隔多类型)匹配货物类型档案货物编码; + * 计量单位:快照运费计算单位(历史快照为空时兜底数量单位),档案暂无编码字段暂以单位名称作为编码。 + * 申请付款金额按各组结算金额占比分配,尾差计入最后一组分录。

+ * + * @param entity 付款申请实体 + * @return 应付单分录解析结果(已分配金额的分录组 + 结算明细单据号) + */ + private BfiEntryGroupResult resolveBfiEntryGroups(PaymentApplication entity) { + List settlementDetails = formalSettlementDetails(entity); + if (Func.isEmpty(settlementDetails)) { + throw new ServiceException("推送金蝶财务应付单失败:正式结算单无结算明细,无法解析物料与计量单位"); + } + // 去重收集结算明细单据号(保持行序),用于拼接财务应付单外部系统来源单据号 + List documentNos = settlementDetails.stream() + .map(FormalSettlementDetail::getDocumentNo) + .filter(Func::isNotBlank) + .map(String::trim) + .distinct() + .toList(); + Map cargoCodeByName = cargoCodeByName(); + LinkedHashMap groupMap = new LinkedHashMap<>(); + // 优先按货物费用行逐条取数(blade_formal_settlement_detail_fee 每货物一条,金额为结算调整后的精确值) + List detailFees = formalSettlementDetailFees(settlementDetails); + if (Func.isNotEmpty(detailFees)) { + for (FormalSettlementDetailFee detailFee : detailFees) { + collectEntryGroups(groupMap, cargoCodeByName, detailFee.getCargoType(), + resolveMeasureunitNumber(null, detailFee.getQuantityUnit()), + money(detailFee.getSettlementAmountTax()), money(detailFee.getTransportQuantity()), + "货物费用[" + Func.toStr(detailFee.getCargoName()) + " 第" + Func.toStr(detailFee.getLineNo()) + "行]"); + } + } else { + for (FormalSettlementDetail settlementDetail : settlementDetails) { + collectEntryGroups(groupMap, cargoCodeByName, settlementDetail.getCargoType(), + resolveMeasureunitNumber(null, settlementDetail.getQuantityUnit()), + money(settlementDetail.getSettlementAmountTax()), money(settlementDetail.getTransportQuantity()), + "结算明细[" + Func.toStr(settlementDetail.getDocumentNo()) + " 第" + Func.toStr(settlementDetail.getLineNo()) + "行]"); + } + } + if (groupMap.isEmpty()) { + throw new ServiceException("推送金蝶财务应付单失败:结算明细未解析出任何分录组,请检查货物类型维护"); + } + // 申请付款金额按各组结算金额占比分配(保留 2 位小数、向下取整,尾差计入最后一组);无结算金额时均摊 + return new BfiEntryGroupResult(allocateEntryAmounts(groupMap, money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP)), documentNos); + } + + /** + * 申请付款金额按各组结算金额占比分配到分录组(保留 2 位小数、向下取整,尾差计入最后一组); + * 各组结算金额合计为 0 时按组数均摊 + * + * @param groupMap 分录组(按物料编码+计量单位聚合,保持行序) + * @param appliedAmount 申请付款金额(已按 2 位小数格式化) + * @return 应付单分录组(已分配金额) + */ + private List allocateEntryAmounts(LinkedHashMap groupMap, BigDecimal appliedAmount) { + List groups = new ArrayList<>(groupMap.values()); + BigDecimal totalBaseAmount = groups.stream().map(BfiEntryGroup::baseAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + List entryGroups = new ArrayList<>(groups.size()); + BigDecimal allocatedAmount = BigDecimal.ZERO; + for (int index = 0; index < groups.size(); index++) { + BfiEntryGroup group = groups.get(index); + BigDecimal amount; + if (index == groups.size() - 1) { + amount = appliedAmount.subtract(allocatedAmount); + } else if (totalBaseAmount.compareTo(BigDecimal.ZERO) > 0) { + amount = appliedAmount.multiply(group.baseAmount()).divide(totalBaseAmount, 2, RoundingMode.DOWN); + } else { + amount = appliedAmount.divide(BigDecimal.valueOf(groups.size()), 2, RoundingMode.DOWN); + } + allocatedAmount = allocatedAmount.add(amount); + entryGroups.add(new BfiEntryGroup(group.materialNumber(), group.measureunitNumber(), group.quantity(), group.baseAmount(), amount)); + } + return entryGroups; + } + + /** + * 单条来源行(货物费用行或结算明细行)按货物类型并入分录组;一行含多种类型时金额与数量按份数均分(尾差计入最后一份) + * + * @param groupMap 分录组(按物料编码+计量单位聚合,保持行序) + * @param cargoCodeByName 货物类型名称->货物编码映射 + * @param cargoType 货物类型(支持“、”分隔多类型) + * @param measureunitNumber 计量单位编码(运费计算单位,档案暂无编码字段暂传单位名称) + * @param baseAmount 结算金额(含税),作为申请金额分配基数 + * @param quantity 运输数量 + * @param sourceDesc 来源行描述,用于异常提示 + */ + private void collectEntryGroups(LinkedHashMap groupMap, Map cargoCodeByName, + String cargoType, String measureunitNumber, BigDecimal baseAmount, BigDecimal quantity, + String sourceDesc) { + List cargoNames = splitCargoTypes(cargoType); + if (Func.isBlank(measureunitNumber)) { + throw new ServiceException("推送金蝶财务应付单失败:" + sourceDesc + "计量单位为空,请先维护运费计算单位"); + } + List baseShares = splitEqually(baseAmount, cargoNames.size()); + List quantityShares = splitEqually(quantity, cargoNames.size()); + for (int index = 0; index < cargoNames.size(); index++) { + String cargoName = cargoNames.get(index); + String materialNumber = cargoCodeByName.get(cargoName); + if (Func.isBlank(materialNumber)) { + throw new ServiceException("推送金蝶财务应付单失败:" + sourceDesc + "货物类型[" + cargoName + "]未在货物类型档案中维护编码,请补充后重试"); + } + groupMap.merge(materialNumber + "/" + measureunitNumber, + new BfiEntryGroup(materialNumber, measureunitNumber, quantityShares.get(index), baseShares.get(index), BigDecimal.ZERO), + (left, right) -> new BfiEntryGroup(left.materialNumber(), left.measureunitNumber(), + left.quantity().add(right.quantity()), left.baseAmount().add(right.baseAmount()), BigDecimal.ZERO)); + } + } + + /** 付款申请关联的正式结算货物费用行(blade_formal_settlement_detail_fee,每货物一条,金额为结算调整后精确值) */ + private List formalSettlementDetailFees(List settlementDetails) { + List settlementDetailIds = settlementDetails.stream().map(FormalSettlementDetail::getId) + .filter(Objects::nonNull).distinct().toList(); + if (Func.isEmpty(settlementDetailIds)) { + return List.of(); + } + return formalSettlementDetailFeeMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetailFee::getFormalSettlementDetailId, settlementDetailIds) + .eq(FormalSettlementDetailFee::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetailFee::getLineNo)); + } + + /** 付款申请关联的正式结算明细(关联表优先,兼容仅有 settlementId 的存量数据) */ + private List formalSettlementDetails(PaymentApplication entity) { + List settlementIds = settlementRelationMapper.selectList(Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)) + .stream().map(PaymentApplicationSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + if (Func.isEmpty(settlementIds) && entity.getSettlementId() != null) { + settlementIds = List.of(entity.getSettlementId()); + } + if (Func.isEmpty(settlementIds)) { + throw new ServiceException("推送金蝶财务应付单失败:付款申请未关联正式结算单,无法解析结算明细"); + } + return formalSettlementDetailMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetail::getFormalSettlementId, settlementIds) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + } + + /** 货物类型名称->货物编码映射(取自货物类型档案;名称重复且编码不一致时拦截) */ + private Map cargoCodeByName() { + R> response = sysClient.getCargoTypes(); + if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { + throw new ServiceException("推送金蝶财务应付单失败:货物类型档案读取失败,请稍后重试"); + } + Map cargoCodeByName = new HashMap<>(); + for (CargoType cargoType : response.getData()) { + if (cargoType == null || Func.isBlank(cargoType.getCargoName()) || Func.isBlank(cargoType.getCargoCode())) { + continue; + } + String cargoName = cargoType.getCargoName().trim(); + String cargoCode = cargoType.getCargoCode().trim(); + String existingCode = cargoCodeByName.putIfAbsent(cargoName, cargoCode); + if (existingCode != null && !existingCode.equals(cargoCode)) { + throw new ServiceException("推送金蝶财务应付单失败:货物类型[" + cargoName + "]在货物类型档案中存在多个编码,请检查档案数据"); + } + } + return cargoCodeByName; + } + + /** + * 解析计量单位编码:优先取快照运费计算单位(快照表 price_unit,随结算自动带出), + * 为空(历史快照)时兜底数量单位。计量单位档案暂无编码字段,暂以单位名称作为编码; + * 档案补充编码字段后在此处增加档案映射。 + * + * @param priceUnit 快照运费计算单位(可能为空) + * @param fallbackUnit 兜底数量单位 + * @return 计量单位编码,无法解析时返回 null + */ + private 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) { + if (Func.isBlank(cargoType)) { + throw new ServiceException("推送金蝶财务应付单失败:结算明细货物类型为空,无法解析物料编码"); + } + return Arrays.stream(cargoType.split("[、,,]")) + .map(String::trim).filter(Func::isNotBlank).distinct().toList(); + } + + /** 数值按份数均分(保留 2 位小数、向下取整,尾差计入最后一份) */ + private List splitEqually(BigDecimal total, int parts) { + if (parts <= 1) { + return List.of(total); + } + List shares = new ArrayList<>(parts); + BigDecimal remaining = total; + for (int index = 0; index < parts; index++) { + if (index == parts - 1) { + shares.add(remaining); + } else { + BigDecimal share = total.divide(BigDecimal.valueOf(parts), 2, RoundingMode.DOWN); + shares.add(share); + remaining = remaining.subtract(share); + } + } + return shares; + } + /** * 校验金蝶保存/下推返回结果(单据级失败以 errors 明细抛出) * @@ -938,11 +1191,18 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl