feat: 金蝶应付/应收/收票单推送支撑组件重构——结算明细行与结算合计费用项同口径,物料/计量单位映射完善

This commit is contained in:
刘泉佳
2026-09-24 19:42:19 +08:00
parent 5225444c3e
commit e923e07947
3 changed files with 528 additions and 346 deletions
@@ -17,7 +17,6 @@
* <p> * <p>
* Using this software signifies agreement to this License, and the software * Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes. * must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development. * not liable for any claims arising from secondary or illegal development.
* <p> * <p>
@@ -30,11 +29,12 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException; import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.api.R; import org.springblade.core.tool.api.R;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache; import org.springblade.system.cache.SysCache;
import org.springblade.system.feign.ISysClient; import org.springblade.system.feign.ISysClient;
import org.springblade.system.pojo.entity.CargoType;
import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.entity.MeasurementUnit;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveEntryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveEntryDTO;
@@ -48,15 +48,16 @@ import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.mapper.ContractManageMapper; import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.CustomerArchiveMapper; import org.springblade.transport.mapper.CustomerArchiveMapper;
import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper; import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper;
import org.springblade.transport.mapper.PreSettlementDetailFeeMapper; import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper; import org.springblade.transport.mapper.FormalSettlementSummaryFeeMapper;
import org.springblade.transport.mapper.ProjectApplyMapper;
import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.entity.CustomerArchive;
import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail; import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.FormalSettlementDetailFee; import org.springblade.transport.pojo.entity.FormalSettlementDetailFee;
import org.springblade.transport.pojo.entity.PreSettlementDetailFee; import org.springblade.transport.pojo.entity.FormalSettlementSummaryFee;
import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee; import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.service.KingdeeSyncContext; import org.springblade.transport.service.KingdeeSyncContext;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -65,7 +66,6 @@ import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -81,6 +81,10 @@ import java.util.stream.Collectors;
* 推送前经 A102-4 预检应付单已生成、下推时经 A102-4 反查付款计划分录 awrp_entry_code_p。 * 推送前经 A102-4 预检应付单已生成、下推时经 A102-4 反查付款计划分录 awrp_entry_code_p。
* 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件; * 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件;
* 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。</p> * 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。</p>
* <p>明细分录口径:取正式结算单页面结算明细(每行一条)+ 结算合计费用项(每行一条);
* 物料编码按项目业务类型/业务模式/装卸大类映射(见 {@link #resolveMaterialNumber});
* 计量单位编码按费用类型匹配合同计费方案规则计费单位,再匹配计量单位档案编码;
* 数量运费费用项目取运输量、其余默认 1;单价按结算金额/数量反算。</p>
* *
* @author Chill * @author Chill
*/ */
@@ -93,13 +97,18 @@ public class KingdeeApBillSyncSupport {
private static final int PAY_RESULT_QUERY_BATCH = 50; private static final int PAY_RESULT_QUERY_BATCH = 50;
/** A102-4 分页查询页大小 */ /** A102-4 分页查询页大小 */
private static final int PAY_RESULT_PAGE_SIZE = 200; private static final int PAY_RESULT_PAGE_SIZE = 200;
/** 结算明细行(运费)计费方案匹配费用项 */
private static final String FREIGHT_FEE_ITEM = "运输费";
/** 结算明细行(运费)计费方案匹配费用类型 */
private static final String FREIGHT_FEE_TYPE = "物流配送";
private final IBfiService bfiService; private final IBfiService bfiService;
private final ContractManageMapper contractManageMapper; private final ContractManageMapper contractManageMapper;
private final CustomerArchiveMapper customerArchiveMapper; private final CustomerArchiveMapper customerArchiveMapper;
private final FormalSettlementDetailFeeMapper formalSettlementDetailFeeMapper; private final FormalSettlementDetailFeeMapper formalSettlementDetailFeeMapper;
private final PreSettlementDetailFeeMapper preSettlementDetailFeeMapper; private final FormalSettlementSummaryFeeMapper formalSettlementSummaryFeeMapper;
private final ReceivablePayableCargoFeeMapper receivablePayableCargoFeeMapper; private final FormalSettlementMapper formalSettlementMapper;
private final ProjectApplyMapper projectApplyMapper;
private final ISysClient sysClient; private final ISysClient sysClient;
private final BfiProperties bfiProperties; private final BfiProperties bfiProperties;
@@ -168,17 +177,18 @@ public class KingdeeApBillSyncSupport {
* 推送金蝶财务应付单(A102-2 财务应付单保存,由正式结算单「同步金蝶」触发)。 * 推送金蝶财务应付单(A102-2 财务应付单保存,由正式结算单「同步金蝶」触发)。
* 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时 * 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。 * 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
* 明细分录逐费用行生成(不聚合),含税金额取应付货物费用行调整后总金额、不含税取结算货物费用行不含税; * 明细分录取页面结算明细(每行一条)+ 结算合计费用项(每行一条),结算金额逐行直送;
* 付款计划 planentity 暂不传(启用方案见方法内注释),当前由金蝶按明细自动汇总生成 * 汇率日期/汇率取结算单页面维护值;付款计划 planentity 暂不传(启用方案见方法内注释),
* 当前由金蝶按明细自动汇总生成
* *
* @param apBillSrcCode 财务应付单防重键(正式结算单号_结算明细单据号拼接) * @param apBillSrcCode 财务应付单防重键(正式结算单号_结算明细单据号拼接)
* @param contractId 正式结算单关联合同id * @param settlement 正式结算单(合同/汇率日期/汇率取数来源)
* @param entryGroups 结算明细解析出的应付单分录 * @param entryGroups 结算明细+结算合计费用项解析出的应付单分录
* @param subject 业务单据号(正式结算单号,日志与异常提示用) * @param subject 业务单据号(正式结算单号,日志与异常提示用)
* @param context 金蝶同步上下文(缓存载体) * @param context 金蝶同步上下文(缓存载体)
* @return 财务应付单推送结果(单据编号 + 是否金蝶已存在幂等跳过) * @return 财务应付单推送结果(单据编号 + 是否金蝶已存在幂等跳过)
*/ */
public BfiApBillPushOutcome pushApBill(String apBillSrcCode, Long contractId, List<BfiEntryGroup> entryGroups, public BfiApBillPushOutcome pushApBill(String apBillSrcCode, FormalSettlement settlement, List<BfiEntryGroup> entryGroups,
String subject, KingdeeSyncContext context) { String subject, KingdeeSyncContext context) {
// A102-4 幂等预检:按防重键查询金蝶是否已生成财务应付单(批量同步时整批已预热缓存,此处仅懒加载未命中编码), // A102-4 幂等预检:按防重键查询金蝶是否已生成财务应付单(批量同步时整批已预热缓存,此处仅懒加载未命中编码),
// 已存在则幂等跳过保存,直接返回已有单据编号 // 已存在则幂等跳过保存,直接返回已有单据编号
@@ -188,7 +198,7 @@ public class KingdeeApBillSyncSupport {
log.info("正式结算单[{}]金蝶财务应付单已生成(单据编号[{}]),幂等跳过 A102-2 保存", subject, existBillNo); log.info("正式结算单[{}]金蝶财务应付单已生成(单据编号[{}]),幂等跳过 A102-2 保存", subject, existBillNo);
return new BfiApBillPushOutcome(existBillNo, true); return new BfiApBillPushOutcome(existBillNo, true);
} }
ContractManage contract = requireContract(contractId, context); ContractManage contract = requireContract(settlement.getContractId(), context);
// 单据类型按映射字典转换:默认服务采购应付,金蝶确认其他类型后修改 BFI_AP_BILL_BILLTYPE_NAME 即可 // 单据类型按映射字典转换:默认服务采购应付,金蝶确认其他类型后修改 BFI_AP_BILL_BILLTYPE_NAME 即可
String billtypeNumber = KingdeeBfiConstant.billtypeBy(KingdeeBfiConstant.AP_BILL_BILLTYPE_MAP, KingdeeBfiConstant.BFI_AP_BILL_BILLTYPE_NAME); String billtypeNumber = KingdeeBfiConstant.billtypeBy(KingdeeBfiConstant.AP_BILL_BILLTYPE_MAP, KingdeeBfiConstant.BFI_AP_BILL_BILLTYPE_NAME);
if (Func.isBlank(billtypeNumber)) { if (Func.isBlank(billtypeNumber)) {
@@ -229,10 +239,16 @@ public class KingdeeApBillSyncSupport {
param.setIsintax(false); param.setIsintax(false);
// 汇率表.编码(非必传) // 汇率表.编码(非必传)
// param.setExratetableNumber(bfiProperties.getPayApply().getExratetable()); // param.setExratetableNumber(bfiProperties.getPayApply().getExratetable());
// 汇率日期(非必传) // 汇率日期:正式结算单页面维护的汇率日期(非必传,未维护不送)
// param.setExratedate(billDateText); if (settlement.getExchangeRateDate() != null) {
param.setExratedate(bfiDateTimeText(settlement.getExchangeRateDate()));
}
// 汇率:正式结算单页面维护的结算汇率(非必传,未维护不送)
if (settlement.getExchangeRate() != null) {
param.setExchangerate(settlement.getExchangeRate());
}
// 往来单位名称(非必传) // 往来单位名称(非必传)
// param.setAsstactname(entity.getPayeeName()); // param.setAsstactname(settlement.getPayeeName());
// 备注(非必传) // 备注(非必传)
// param.setRemark(subject); // param.setRemark(subject);
// 【必填】外部系统编号(候选键) // 【必填】外部系统编号(候选键)
@@ -248,38 +264,94 @@ public class KingdeeApBillSyncSupport {
// 【必填】外部单据类型 // 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE); param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE);
// 明细分录:每条应付货物费用行一条(不聚合),无吨位时数量兜底 1 // 明细分录:结算明细行 + 结算合计费用项各一条(不聚合)
List<BfiApBillSaveEntryDTO> detailentry = new ArrayList<>(); List<BfiApBillSaveEntryDTO> detailentry = new ArrayList<>();
for (BfiEntryGroup entryGroup : entryGroups) { for (BfiEntryGroup entryGroup : entryGroups) {
BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO(); BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO();
// 【必填】物料编码.编码:结算明细货物类型匹配货物类型档案货物编码 // 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(国内道路货物运输服务-运输方式 / 国际货物运输代理服务 / 装卸)
entry.setMaterialNumber(entryGroup.materialNumber()); String materialNumber = entryGroup.materialNumber();
// 【必填】计量单位.编码:货物费用行运费计算单位(快照 price_unit,档案暂无编码字段暂传单位名称)
entry.setMaterialNumber(materialNumber);
// 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传
entry.setAwrpTransport(entryGroup.transportGoods());
// 车号(金蝶接口新字段):结算明细车号,结算合计费用项行不传
entry.setAwrpLpn(entryGroup.vehicleNo());
// 【必填】计量单位.编码:费用类型匹配合同计费方案计费单位,再匹配计量单位档案编码
entry.setMeasureunitNumber(entryGroup.measureunitNumber()); entry.setMeasureunitNumber(entryGroup.measureunitNumber());
// 【必填】数量:该行运输数量,无吨位数据时兜底 1 // 【必填】数量:运费费用项目取运输量,其它费用项目默认 1
entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
// 明细.单价:该行运输单价(费用明细 unit_price;单据头 ispricetotal=true 录入总价,单价仅作参考) // 明细.单价:结算金额/数量反算(单据头 ispricetotal=true 录入总价,单价仅作参考)
entry.setPrice(entryGroup.price()); entry.setPrice(entryGroup.price());
// 应付金额(含税):该行应付货物费用行调整后总金额(含税) // 应付金额(含税):结算明细行取结算金额(含税),结算合计费用项行取合计行结算金额
entry.setEPricetaxtotal(entryGroup.amount()); entry.setEPricetaxtotal(entryGroup.amount());
// 金额(不含税):该行结算金额(不含税)(应付费用行无不含税字段,经结算货物费用行回链) // 金额(不含税):结算明细行结算金额(不含税),结算合计费用项行无不含税金额不传
entry.setEAmount(entryGroup.amountNoTax()); entry.setEAmount(entryGroup.amountNoTax());
// 【必填】项目.项目编码(联调期临时传金蝶测试档案值) // 【必填】项目.项目编码(联调期临时传金蝶测试档案值)
entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST); entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST);
// 【必填】外部系统来源分录标识(候选键):费用明细行id(唯一),A103-1 下推时 awrp_entry_code 与此对应 // 【必填】外部系统来源分录标识(候选键):结算明细行取单据号,结算合计费用项行取结算单号-费用类型-费用项拼接
entry.setAwrpEntryCode(entryGroup.entryCode()); entry.setAwrpEntryCode(entryGroup.entryCode());
// 调试
if (true) {
// 【必填】物料编码.编码:结算明细货物类型匹配货物类型档案货物编码
entry.setMaterialNumber(KingdeeBfiConstant.BFI_AP_BILL_MATERIAL_NUMBER_TEST);
// 【必填】计量单位.编码:货物费用行运费计算单位(快照 price_unit,档案暂无编码字段暂传单位名称)
entry.setMeasureunitNumber(KingdeeBfiConstant.BFI_AP_BILL_MEASUREUNIT_NUMBER_TEST);
}
// 分录备注(非必传) // 分录备注(非必传)
// entry.setERemark(subject); // entry.setERemark(subject);
// 调试:物料编码和计量单位编码映射(4个固定组合)
if (true) {
// 物料+单位组合映射,只保留【已审核】物料
Map<String, String[]> comboMap = Map.of(
"国际货运代理服务费", new String[]{"Item-00001081", "ci"},
"物流配送", new String[]{"Item-00001080", "TON"},
"国内道路货物运输服务-公路运输-吨", new String[]{"XDWL11010001", "TON"},
"国内道路货物运输服务-公路运输-公斤", new String[]{"XDWL11010002", "KG"}
);
// ⚠️兜底改成【已审核】物流配送 Item-00001080 + TON
String[] defaultCombo = comboMap.get("物流配送");
// 构建组合键:物料名称-计量单位
String measureunitName = entryGroup.measureunitNumber();
String comboKey = materialNumber;
if (materialNumber.contains("国内道路货物运输服务")) {
comboKey = materialNumber + "-" + measureunitName;
}
// 匹配组合,不匹配使用兜底组合
String[] combo = comboMap.getOrDefault(comboKey, defaultCombo);
materialNumber = combo[0];
entry.setMaterialNumber(materialNumber);
entry.setMeasureunitNumber(combo[1]);
}
/*if (true) {
// 物料+单位组合映射
Map<String, String[]> comboMap = Map.of(
"国际货运代理服务费", new String[]{"Item-00001081", "ci"},
"物流配送", new String[]{"Item-00001080", "TON"},
"国内道路货物运输服务-公路运输-吨", new String[]{"XDWL11010001", "TON"},
"国内道路货物运输服务-公路运输-公斤", new String[]{"XDWL11010002", "KG"}
);
// 构建组合键:物料名称-计量单位
String measureunitName = entryGroup.measureunitNumber();
String comboKey = materialNumber;
if (materialNumber.contains("国内道路货物运输服务")) {
comboKey = materialNumber + "-" + measureunitName;
}
// 匹配组合或随机取一个
if (comboMap.containsKey(comboKey)) {
String[] combo = comboMap.get(comboKey);
materialNumber = combo[0];
entry.setMeasureunitNumber(combo[1]);
} else {
List<String[]> combos = new ArrayList<>(comboMap.values());
String[] randomCombo = combos.get(new java.util.Random().nextInt(combos.size()));
materialNumber = randomCombo[0];
entry.setMeasureunitNumber(randomCombo[1]);
}
}*/
detailentry.add(entry); detailentry.add(entry);
} }
param.setDetailentry(detailentry); param.setDetailentry(detailentry);
@@ -289,211 +361,338 @@ public class KingdeeApBillSyncSupport {
// 当前不传由金蝶按明细自动汇总生成,A103-1 推送前经 A102-4 反查金蝶生成的 awrp_entry_code_p // 当前不传由金蝶按明细自动汇总生成,A103-1 推送前经 A102-4 反查金蝶生成的 awrp_entry_code_p
// TODO 待业务确认:存在预付款抵扣时是否需传预付信息 preentry(y_billtype/y_billid/y_settleamt),本期不传、后续迭代 // TODO 待业务确认:存在预付款抵扣时是否需传预付信息 preentry(y_billtype/y_billid/y_settleamt),本期不传、后续迭代
// 推送入参摘要(联调排查:防重键、分录条数、页面维护的汇率口径)
log.info("推送金蝶财务应付单:正式结算单[{}],防重键[{}],明细分录{}条,汇率日期[{}],汇率[{}]",
subject, apBillSrcCode, detailentry.size(), param.getExratedate(), param.getExchangerate());
BfiBillSaveResultVO saveResult = bfiService.saveApBill(param); BfiBillSaveResultVO saveResult = bfiService.saveApBill(param);
// 应付单id不落库,后续如需可经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询 // 应付单id不落库,后续如需可经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询
requireBfiSaveSuccess(saveResult, "推送金蝶财务应付单"); requireBfiSaveSuccess(saveResult, "推送金蝶财务应付单");
return new BfiApBillPushOutcome(saveResult.getResult().get(0).getNumber(), false); String billNo = saveResult.getResult().get(0).getNumber();
log.info("金蝶财务应付单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, detailentry.size());
return new BfiApBillPushOutcome(billNo, false);
} }
// ==================== 结算明细分录解析 ==================== // ==================== 结算明细分录解析(结算明细行 + 结算合计费用项) ====================
/** /**
* 解析正式结算明细,逐行生成应付单分录(不聚合,每条来源费用行一条分录),分录携带所属正式结算单id * 解析正式结算明细为应付单分录(不聚合):按所属正式结算单分组,逐结算单取页面结算明细
* (每行一条分录)与结算合计费用项(每行一条分录);分录携带所属正式结算单id
* (多结算单付款申请 A103-1 下推时按结算单分组勾稽各自应付单付款计划分录)。 * (多结算单付款申请 A103-1 下推时按结算单分组勾稽各自应付单付款计划分录)。
* <p>取数来源:应付明细货物费用行(blade_receivable_payable_cargo_fee,经结算明细 sourceDetailId 关联应付明细, * 应收单(A302-2)、收票单(A303-2)分录与本解析同源同口径
* 每货物一条)——货物类型/计量单位/运输数量/含税金额直接取应付费用行(含税金额取数链:调整后总金额 after_amount →
* 原总金额 → 单价×数量兜底,见 {@link #resolveFeeAmount});
* 应付费用行无不含税字段,不含税金额经结算货物费用行 sourceFeeId 回链(直接选明细路径 sourceFeeId=应付费用行id,
* 预结算合并路径 sourceFeeId=预结算费用行id,经预结算费用行 sourceFeeId 回溯应付费用行id);
* 结算明细对应应付明细无货物费用行时直接报错拦截(取数必须来自应付费用明细,无兜底)。物料编码:货物类型(支持“、”分隔多类型)
* 匹配货物类型档案货物编码;分录唯一标识(awrp_entry_code)取费用明细行id(雪花id天然唯一,满足 A103-1
* 对方系统唯一行id不能重复;A103-1 与应付单的勾稽关联走 awrp_finapbill_entryid=付款计划
* awrp_entry_code_p,与本标识无关);分录金额不按申请付款金额分摊。</p>
* *
* @param settlementDetails 正式结算明细列表 * @param settlementDetails 正式结算明细列表(可能跨多张结算单)
* @param context 金蝶同步上下文(缓存载体) * @param context 金蝶同步上下文(缓存载体)
* @return 应付单分录列表(保持行序) * @return 应付单分录列表(保持行序)
*/ */
public List<BfiEntryGroup> resolveEntryGroups(List<FormalSettlementDetail> settlementDetails, KingdeeSyncContext context) { public List<BfiEntryGroup> resolveEntryGroups(List<FormalSettlementDetail> settlementDetails, KingdeeSyncContext context) {
if (Func.isEmpty(settlementDetails)) { if (Func.isEmpty(settlementDetails)) {
throw new ServiceException("推送金蝶财务应付单失败:正式结算单无结算明细,无法解析物料与计量单位"); throw new ServiceException("推送金蝶失败:正式结算单无结算明细,无法解析分录");
} }
Map<String, String> cargoCodeByName = cargoCodeByName(context); Map<Long, List<FormalSettlementDetail>> detailsBySettlementId = new LinkedHashMap<>();
// 应付明细id->应付货物费用行(分录主取数来源)
Map<Long, List<ReceivablePayableCargoFee>> payableFeesByDetailId = payableCargoFeesByDetailId(settlementDetails);
// 应付费用行id->结算金额(不含税):应付费用行无不含税字段,经结算货物费用行 sourceFeeId 回链
Map<Long, BigDecimal> noTaxByPayableFeeId = settlementNoTaxByPayableFeeId(settlementDetails);
List<BfiEntryGroup> entryGroups = new ArrayList<>();
for (FormalSettlementDetail settlementDetail : settlementDetails) { for (FormalSettlementDetail settlementDetail : settlementDetails) {
List<ReceivablePayableCargoFee> payableFees = if (settlementDetail.getFormalSettlementId() == null) {
payableFeesByDetailId.getOrDefault(settlementDetail.getSourceDetailId(), List.of()); throw new ServiceException("推送金蝶失败:结算明细[" + Func.toStr(settlementDetail.getDocumentNo()) + "]未关联正式结算单");
if (Func.isEmpty(payableFees)) {
throw new ServiceException("推送金蝶财务应付单失败:结算明细[" + Func.toStr(settlementDetail.getDocumentNo())
+ "]对应应付明细无货物费用行,分录取数必须来自应付费用明细,请先维护费用数据");
}
for (ReceivablePayableCargoFee payableFee : payableFees) {
String sourceDesc = "应付货物费用[" + Func.toStr(payableFee.getCargoName()) + " 第" + Func.toStr(payableFee.getLineNo()) + "行]";
appendEntryGroups(entryGroups, cargoCodeByName, settlementDetail.getFormalSettlementId(),
payableFee.getCargoType(),
resolveMeasureunitNumber(payableFee.getPriceUnit(), payableFee.getQuantityUnit()),
payableFee.getId(), money(payableFee.getUnitPrice()),
resolveFeeAmount(payableFee, sourceDesc),
noTaxByPayableFeeId.getOrDefault(payableFee.getId(), BigDecimal.ZERO),
money(payableFee.getTransportQuantity()),
sourceDesc);
} }
detailsBySettlementId.computeIfAbsent(settlementDetail.getFormalSettlementId(), key -> new ArrayList<>()).add(settlementDetail);
} }
List<BfiEntryGroup> entryGroups = new ArrayList<>();
detailsBySettlementId.forEach((settlementId, details) ->
entryGroups.addAll(this.resolveEntryGroups(requireSettlement(settlementId, context), details, context)));
return entryGroups; return entryGroups;
} }
/** /**
* 单条应付货物费用行生成一条分录; * 解析单张正式结算单分录:页面结算明细每行一条 + 结算合计费用项每行一条。
* 货物类型含多种(“、/,”分隔)时不拆行,物料编码取第一种货物类型,金额数量整行直送 * <p>取数口径:
* 物料编码按项目业务类型/业务模式/装卸大类映射——装卸大类费用行映射“装卸”;国际运输业务模式
* (或国际货物运输代理服务项目业务类型)映射“国际货物运输代理服务”;国内映射“国内道路货物运输服务-”+运单运输方式;
* 计量单位编码按费用类型/费用项匹配合同计费方案规则计费单位,再按计量单位档案取编码;
* 数量运费费用项目取运输量(未维护兜底 1)、结算合计费用项默认 1;单价按结算金额/数量反算;
* 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额;
* 运输货物/车号仅结算明细行传(货物名称/车号);分录标识:结算明细行取单据号、
* 结算合计费用项行取结算单号-费用类型-费用项拼接。</p>
* *
* @param entryGroups 分录集合(保持行序) * @param settlement 正式结算单
* @param cargoCodeByName 货物类型名称->货物编码映射 * @param settlementDetails 该结算单下的结算明细
* @param formalSettlementId 分录所属正式结算单id * @param context 金蝶同步上下文(缓存载体)
* @param cargoType 货物类型(支持“、”分隔多类型) * @return 应付单分录列表(保持行序)
* @param measureunitNumber 计量单位编码(运费计算单位,档案暂无编码字段暂传单位名称)
* @param sourceRowId 应付货物费用行id,雪花id天然唯一
* @param price 该行运输单价(费用明细 unit_price)
* @param amount 该行金额(含税)
* @param amountNoTax 该行金额(不含税)
* @param quantity 该行运输数量
* @param sourceDesc 来源行描述,用于异常提示
*/ */
private void appendEntryGroups(List<BfiEntryGroup> entryGroups, Map<String, String> cargoCodeByName, public List<BfiEntryGroup> resolveEntryGroups(FormalSettlement settlement, List<FormalSettlementDetail> settlementDetails,
Long formalSettlementId, String cargoType, String measureunitNumber, Long sourceRowId, KingdeeSyncContext context) {
BigDecimal price, BigDecimal amount, BigDecimal amountNoTax, BigDecimal quantity, if (Func.isEmpty(settlementDetails)) {
String sourceDesc) { throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo()) + "]无结算明细,无法解析分录");
if (Func.isBlank(measureunitNumber)) {
throw new ServiceException("推送金蝶财务应付单失败:" + sourceDesc + "计量单位为空,请先维护运费计算单位");
} }
// 货物类型含多种时不拆行(业务约定整行一条分录),物料编码取第一种货物类型 ProjectApply project = requireProject(settlement.getProjectId(), context);
String cargoName = splitCargoTypes(cargoType).get(0); ContractManage contract = requireContract(settlement.getContractId(), context);
String materialNumber = cargoCodeByName.get(cargoName); // 合同计费方案规则:费用类型/费用项 -> 计费单位
if (Func.isBlank(materialNumber)) { Map<String, String> billingUnitByRule = billingUnitByRule(contract);
throw new ServiceException("推送金蝶财务应付单失败:" + sourceDesc + "货物类型[" + cargoName + "]未在货物类型档案中维护编码,请补充后重试"); // 计量单位档案:单位名称 -> 计量单位编码
Map<String, String> unitCodeByName = measureUnitCodeByName(context);
// 结算明细费用行:判断运单是否存在装卸大类费用
Map<Long, List<FormalSettlementDetailFee>> feesByDetailId = detailFeesByDetailId(settlementDetails);
List<BfiEntryGroup> entryGroups = new ArrayList<>();
for (FormalSettlementDetail settlementDetail : settlementDetails) {
appendDetailEntry(entryGroups, settlement, project, settlementDetail,
feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()), billingUnitByRule, unitCodeByName);
} }
// 分录唯一标识(awrp_entry_code,A103-1 要求对方系统唯一行id不能重复):应付货物费用行id,雪花id天然唯一 // 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复
String entryCode = String.valueOf(sourceRowId); List<FormalSettlementSummaryFee> manualSummaryFees = listSummaryFees(settlement.getId()).stream()
entryGroups.add(new BfiEntryGroup(materialNumber, measureunitNumber, entryCode, .filter(summaryFee -> Integer.valueOf(1).equals(summaryFee.getManualFlag())).toList();
quantity, price, amount, amountNoTax, formalSettlementId)); for (FormalSettlementSummaryFee summaryFee : manualSummaryFees) {
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]", appendSummaryEntry(entryGroups, settlement, project, summaryFee,
sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount, amountNoTax); settlementDetails.get(0).getTransportType(), billingUnitByRule, unitCodeByName);
}
/** 付款申请关联的正式结算货物费用行(blade_formal_settlement_detail_fee,用于不含税金额回链应付费用行;应收单同步组件复用) */
public List<FormalSettlementDetailFee> formalSettlementDetailFees(List<FormalSettlementDetail> settlementDetails) {
List<Long> settlementDetailIds = settlementDetails.stream().map(FormalSettlementDetail::getId)
.filter(Objects::nonNull).distinct().toList();
if (Func.isEmpty(settlementDetailIds)) {
return List.of();
} }
return formalSettlementDetailFeeMapper.selectList(Wrappers.<FormalSettlementDetailFee>lambdaQuery() if (entryGroups.isEmpty()) {
.in(FormalSettlementDetailFee::getFormalSettlementDetailId, settlementDetailIds) throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo())
.eq(FormalSettlementDetailFee::getIsDeleted, 0) + "]无结算明细与结算合计费用项,无法解析分录");
.orderByAsc(FormalSettlementDetailFee::getLineNo));
}
/** 应付明细id->应付货物费用行(blade_receivable_payable_cargo_fee,经结算明细 sourceDetailId 关联,每货物一条,分录主取数来源;应收单同步组件复用) */
public Map<Long, List<ReceivablePayableCargoFee>> payableCargoFeesByDetailId(List<FormalSettlementDetail> settlementDetails) {
List<Long> sourceDetailIds = settlementDetails.stream()
.map(FormalSettlementDetail::getSourceDetailId).filter(Objects::nonNull).distinct().toList();
if (Func.isEmpty(sourceDetailIds)) {
return Map.of();
} }
return receivablePayableCargoFeeMapper.selectList(Wrappers.<ReceivablePayableCargoFee>lambdaQuery() // 分录解析摘要(联调排查:明细条数=结算明细行+手工添加的结算合计费用项,物料/计量单位/分录标识逐条见下方分录生成日志)
.in(ReceivablePayableCargoFee::getDetailId, sourceDetailIds) log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条 + 手工结算合计{}条 = 共{}条分录",
.eq(ReceivablePayableCargoFee::getIsDeleted, 0) settlement.getFormalSettlementNo(), settlementDetails.size(),
.orderByAsc(ReceivablePayableCargoFee::getLineNo)) entryGroups.size() - settlementDetails.size(), entryGroups.size());
.stream().collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId)); return entryGroups;
} }
/** /**
* 应付货物费用行id->结算金额(不含税):应付费用行无不含税字段,经结算货物费用行 sourceFeeId 回链获取。 * 单条结算明细行生成一条分录:物料按项目业务类型/业务模式/装卸大类映射,数量取运输量(兜底 1),
* 直接选应收应付明细路径 sourceFeeId=应付费用行id;预结算合并路径 sourceFeeId=预结算费用行id, * 金额取结算金额(含税),不含税取结算金额(不含税),运输货物/车号取货物名称/车号,分录标识取单据号
* 经预结算费用行 sourceFeeId(指向应付费用行)回溯;无对应结算费用行时无映射,调用方兜底 0(应收单同步组件复用)
*/ */
public Map<Long, BigDecimal> settlementNoTaxByPayableFeeId(List<FormalSettlementDetail> settlementDetails) { private void appendDetailEntry(List<BfiEntryGroup> entryGroups, FormalSettlement settlement, ProjectApply project,
List<FormalSettlementDetailFee> settlementFees = formalSettlementDetailFees(settlementDetails); FormalSettlementDetail detail, List<FormalSettlementDetailFee> detailFees,
if (Func.isEmpty(settlementFees)) { Map<String, String> billingUnitByRule, Map<String, String> unitCodeByName) {
return Map.of(); String sourceDesc = "结算明细[" + Func.toStr(detail.getDocumentNo()) + "]";
} // 该条运单存在装卸大类费用(明细费用项或明细费用行费用项含“装卸”)时物料映射为装卸
// 上游费用行id(应付费用行id或预结算费用行id)-> 不含税合计 boolean loadingFee = hasLoadingFeeItem(detail.getFeeItemsJson())
Map<Long, BigDecimal> noTaxBySourceFeeId = new LinkedHashMap<>(); || detailFees.stream().anyMatch(fee -> hasLoadingFeeItem(fee.getFeeItemsJson()));
for (FormalSettlementDetailFee settlementFee : settlementFees) { String materialNumber = resolveMaterialNumber(loadingFee, project, detail.getTransportType(), sourceDesc);
if (settlementFee.getSourceFeeId() == null) { // 计量单位编码:运费费用项匹配合同计费方案计费单位,再匹配计量单位档案编码
continue; String measureunitNumber = resolveMeasureunitNumber(billingUnitByRule, unitCodeByName, FREIGHT_FEE_ITEM, FREIGHT_FEE_TYPE, sourceDesc);
} // 数量:运费费用项目取运输量,未维护兜底 1
noTaxBySourceFeeId.merge(settlementFee.getSourceFeeId(), money(settlementFee.getSettlementAmountNoTax()), BigDecimal::add); BigDecimal quantity = detail.getTransportQuantity() != null && detail.getTransportQuantity().compareTo(BigDecimal.ZERO) > 0
} ? money(detail.getTransportQuantity()) : BigDecimal.ONE;
if (noTaxBySourceFeeId.isEmpty()) { BigDecimal amount = money(detail.getSettlementAmountTax());
return Map.of(); BigDecimal price = entryPrice(amount, quantity);
} // 分录唯一标识(awrp_entry_code):结算明细单据号;未维护时兜底明细行id,避免候选键为空被金蝶拦截
// sourceFeeId 指向预结算费用行的:经预结算费用行回溯应付费用行id(直接路径无预结算费用行,保持原 id) String entryCode = Func.isBlank(detail.getDocumentNo()) ? String.valueOf(detail.getId()) : detail.getDocumentNo().trim();
Map<Long, Long> payableFeeIdByPreFeeId = preSettlementDetailFeeMapper.selectList( entryGroups.add(new BfiEntryGroup(materialNumber, measureunitNumber, entryCode, quantity, price, amount,
Wrappers.<PreSettlementDetailFee>lambdaQuery().in(PreSettlementDetailFee::getId, new ArrayList<>(noTaxBySourceFeeId.keySet()))) money(detail.getSettlementAmountNoTax()), detail.getCargoName(), detail.getVehicleNo(), settlement.getId()));
.stream().filter(preSettlementFee -> preSettlementFee.getSourceFeeId() != null) log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]",
.collect(Collectors.toMap(PreSettlementDetailFee::getId, PreSettlementDetailFee::getSourceFeeId, (left, right) -> left)); sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount, money(detail.getSettlementAmountNoTax()));
Map<Long, BigDecimal> noTaxByPayableFeeId = new LinkedHashMap<>();
noTaxBySourceFeeId.forEach((sourceFeeId, noTax) ->
noTaxByPayableFeeId.merge(payableFeeIdByPreFeeId.getOrDefault(sourceFeeId, sourceFeeId), noTax, BigDecimal::add));
return noTaxByPayableFeeId;
} }
/** 货物类型名称->货物编码映射(取自货物类型档案;名称重复且编码不一致时拦截;批量同步时全批共享一次加载结果;应收单同步组件复用) */ /**
public Map<String, String> cargoCodeByName(KingdeeSyncContext context) { * 单条结算合计费用项生成一条分录:物料按装卸大类/项目业务类型映射(运输方式取结算明细首行),
Map<String, String> cache = context.cargoCodeByName(); * 数量默认 1,金额取合计行结算金额,运输货物/车号不传,分录标识取结算单号-费用类型-费用项拼接
*/
private void appendSummaryEntry(List<BfiEntryGroup> entryGroups, FormalSettlement settlement, ProjectApply project,
FormalSettlementSummaryFee summaryFee, String fallbackTransportType,
Map<String, String> billingUnitByRule, Map<String, String> unitCodeByName) {
String feeType = Func.toStr(summaryFee.getFeeType()).trim();
String feeItem = Func.toStr(summaryFee.getFeeItem()).trim();
String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]";
boolean loadingFee = feeType.contains("装卸") || feeItem.contains("装卸");
String materialNumber = resolveMaterialNumber(loadingFee, project, fallbackTransportType, sourceDesc);
String measureunitNumber = resolveMeasureunitNumber(billingUnitByRule, unitCodeByName, feeItem, feeType, sourceDesc);
// 结算合计费用项行无运输量,数量默认 1
BigDecimal quantity = BigDecimal.ONE;
BigDecimal amount = money(summaryFee.getSettlementAmount());
BigDecimal price = entryPrice(amount, quantity);
// 分录唯一标识(awrp_entry_code):结算单号-费用类型-费用项(费用项为空时仅拼结算单号-费用类型)
String entryCode = settlement.getFormalSettlementNo() + "-"
+ (Func.isBlank(feeItem) ? feeType : feeType + "-" + feeItem);
entryGroups.add(new BfiEntryGroup(materialNumber, measureunitNumber, entryCode, quantity, price, amount,
null, null, null, settlement.getId()));
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}]",
sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount);
}
/**
* 物料编码映射:装卸大类费用映射“装卸”;业务模式/项目业务类型含“国际”(国际运输、国际货物运输代理服务)
* 映射“国际货物运输代理服务”;其余(国内运输/国内道路货物运输服务)拼接运单运输方式,
* 如“国内道路货物运输服务-水路”
*
* @param loadingFee 该行是否属于装卸大类费用
* @param project 结算单关联项目(业务类型/业务模式取数来源)
* @param transportType 运单运输方式(结算合计费用项行取结算明细首行运输方式)
* @param sourceDesc 来源行描述,用于异常提示
* @return 金蝶物料编码(按映射规则拼出的物料名称)
*/
private String resolveMaterialNumber(boolean loadingFee, ProjectApply project, String transportType, String sourceDesc) {
String materialNumber;
if (loadingFee) {
materialNumber = KingdeeBfiConstant.BFI_MATERIAL_LOADING;
} else if (isInternationalBusiness(project)) {
materialNumber = KingdeeBfiConstant.BFI_MATERIAL_INTERNATIONAL;
} else {
if (Func.isBlank(transportType)) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "运单运输方式为空,无法拼接国内道路货物运输服务物料,请先维护运输方式");
}
materialNumber = KingdeeBfiConstant.BFI_MATERIAL_DOMESTIC_PREFIX + transportType.trim();
}
// 物料映射判定依据(联调排查:装卸大类/业务模式/项目业务类型/运输方式 -> 物料)
log.info("金蝶物料映射:来源[{}],装卸大类[{}],业务模式[{}],项目业务类型[{}],运单运输方式[{}] -> 物料编码[{}]",
sourceDesc, loadingFee ? "是" : "否",
project == null ? null : project.getBusinessMode(), project == null ? null : project.getBusinessType(),
transportType, materialNumber);
return materialNumber;
}
/** 项目业务模式/业务类型含“国际”即视为国际运输(映射国际货代服务物料);项目缺失时拦截 */
private boolean isInternationalBusiness(ProjectApply project) {
if (project == null) {
throw new ServiceException("推送金蝶失败:结算单未关联项目,无法按项目业务类型/业务模式匹配物料");
}
return containsInternational(project.getBusinessMode()) || containsInternational(project.getBusinessType());
}
private boolean containsInternational(String text) {
return text != null && text.contains("国际");
}
/**
* 计量单位编码解析:费用项优先、费用类型兜底匹配合同计费方案规则计费单位,
* 再按计量单位档案(单位名称匹配)取计量单位编码,任一环节缺失直接拦截报错
*
* @param billingUnitByRule 合同计费方案:费用类型/费用项 -> 计费单位
* @param unitCodeByName 计量单位档案:单位名称 -> 计量单位编码
* @param feeItem 费用项目(结算合计费用项行取行费用项,结算明细行固定运输费)
* @param feeType 费用类型(结算合计费用项行取行费用类型,结算明细行固定物流配送)
* @param sourceDesc 来源行描述,用于异常提示
* @return 计量单位编码
*/
private String resolveMeasureunitNumber(Map<String, String> billingUnitByRule, Map<String, String> unitCodeByName,
String feeItem, String feeType, String sourceDesc) {
String billingUnit = Func.isBlank(feeItem) ? null : billingUnitByRule.get(feeItem.trim());
if (Func.isBlank(billingUnit) && Func.isNotBlank(feeType)) {
billingUnit = billingUnitByRule.get(feeType.trim());
}
if (Func.isBlank(billingUnit)) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "未在合同计费方案中配置计费单位,请先维护计费方案规则");
}
String unitCode = unitCodeByName.get(billingUnit.trim());
if (Func.isBlank(unitCode)) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "计费单位[" + billingUnit + "]未在计量单位档案中维护编码,请补充后重试");
}
// 计量单位匹配链路(联调排查:费用项/费用类型 -> 合同计费方案计费单位 -> 计量单位档案编码)
log.info("金蝶计量单位匹配:来源[{}],费用项[{}],费用类型[{}] -> 计费单位[{}] -> 计量单位编码[{}]",
sourceDesc, feeItem, feeType, billingUnit, unitCode);
return unitCode;
}
/**
* 合同计费方案规则解析:费用类型/费用项 -> 计费单位(取首个配置了计费单位的规则,
* 计费方案 JSON 解析失败按未配置处理,由计量单位解析处统一拦截报错)
*
* @param contract 结算合同
* @return 费用类型/费用项 -> 计费单位映射
*/
private Map<String, String> billingUnitByRule(ContractManage contract) {
Map<String, String> mapping = new HashMap<>();
if (Func.isBlank(contract.getBillingPlanJson())) {
return mapping;
}
try {
Object parsed = JsonUtil.parse(contract.getBillingPlanJson(), List.class);
if (!(parsed instanceof List<?> plans)) {
return mapping;
}
for (Object planValue : plans) {
if (!(planValue instanceof Map<?, ?> plan) || !(plan.get("rules") instanceof List<?> rules)) {
continue;
}
for (Object ruleValue : rules) {
if (!(ruleValue instanceof Map<?, ?> rule)) {
continue;
}
String billingUnit = textValue(rule.get("billingUnit"));
if (Func.isBlank(billingUnit)) {
continue;
}
String feeItem = textValue(rule.get("feeItem"));
String feeType = textValue(rule.get("feeType"));
if (Func.isNotBlank(feeItem)) {
mapping.putIfAbsent(feeItem, billingUnit);
}
if (Func.isNotBlank(feeType)) {
mapping.putIfAbsent(feeType, billingUnit);
}
}
}
} catch (RuntimeException ignored) {
// 计费方案 JSON 兼容历史数据,解析失败按未配置处理
}
log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置计费单位{}", contract.getContractNo(), mapping.size(), mapping);
return mapping;
}
/** 费用项 JSON 是否包含装卸大类费用(费用项名称含“装卸”) */
private boolean hasLoadingFeeItem(String feeItemsJson) {
if (Func.isEmpty(feeItemsJson)) {
return false;
}
try {
Object parsed = JsonUtil.parse(feeItemsJson, Map.class);
if (parsed instanceof Map<?, ?> map) {
return map.keySet().stream().map(String::valueOf).anyMatch(key -> key.contains("装卸"));
}
} catch (RuntimeException ignored) {
// 兼容历史费用JSON
}
return false;
}
private String textValue(Object value) {
return value == null ? null : String.valueOf(value).trim();
}
/** 结算明细id -> 结算明细费用行(判断运单是否存在装卸大类费用) */
private Map<Long, List<FormalSettlementDetailFee>> detailFeesByDetailId(List<FormalSettlementDetail> settlementDetails) {
List<Long> detailIds = settlementDetails.stream().map(FormalSettlementDetail::getId)
.filter(Objects::nonNull).distinct().toList();
if (Func.isEmpty(detailIds)) {
return Map.of();
}
return formalSettlementDetailFeeMapper.selectList(Wrappers.<FormalSettlementDetailFee>lambdaQuery()
.in(FormalSettlementDetailFee::getFormalSettlementDetailId, detailIds)
.eq(FormalSettlementDetailFee::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetailFee::getLineNo))
.stream().collect(Collectors.groupingBy(FormalSettlementDetailFee::getFormalSettlementDetailId));
}
/** 正式结算合计费用项(页面结算合计,保持行序) */
public List<FormalSettlementSummaryFee> listSummaryFees(Long settlementId) {
return formalSettlementSummaryFeeMapper.selectList(Wrappers.<FormalSettlementSummaryFee>lambdaQuery()
.eq(FormalSettlementSummaryFee::getFormalSettlementId, settlementId)
.eq(FormalSettlementSummaryFee::getIsDeleted, 0)
.orderByAsc(FormalSettlementSummaryFee::getLineNo));
}
/**
* 计量单位档案:单位名称 -> 计量单位编码(批量同步时全批共享一次加载结果;
* 应收单同步组件复用)
*/
public Map<String, String> measureUnitCodeByName(KingdeeSyncContext context) {
Map<String, String> cache = context.measureUnitCodeByName();
if (!cache.isEmpty()) { if (!cache.isEmpty()) {
return cache; return cache;
} }
R<List<CargoType>> response = sysClient.getCargoTypes(); R<List<MeasurementUnit>> response = sysClient.getMeasurementUnits();
if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) {
throw new ServiceException("推送金蝶财务应付单失败:货物类型档案读取失败,请稍后重试"); throw new ServiceException("推送金蝶失败:计量单位档案读取失败,请稍后重试");
} }
Map<String, String> loaded = new HashMap<>(); Map<String, String> loaded = new HashMap<>();
for (CargoType cargoType : response.getData()) { for (MeasurementUnit measurementUnit : response.getData()) {
if (cargoType == null || Func.isBlank(cargoType.getCargoName()) || Func.isBlank(cargoType.getCargoCode())) { if (measurementUnit == null || Func.isBlank(measurementUnit.getUnitName()) || Func.isBlank(measurementUnit.getUnitCode())) {
continue; continue;
} }
String cargoName = cargoType.getCargoName().trim(); loaded.putIfAbsent(measurementUnit.getUnitName().trim(), measurementUnit.getUnitCode().trim());
String cargoCode = cargoType.getCargoCode().trim();
String existingCode = loaded.putIfAbsent(cargoName, cargoCode);
if (existingCode != null && !existingCode.equals(cargoCode)) {
throw new ServiceException("推送金蝶财务应付单失败:货物类型[" + cargoName + "]在货物类型档案中存在多个编码,请检查档案数据");
}
} }
log.info("计量单位档案加载:{}项(单位名称 -> 金蝶计量单位编码)", loaded.size());
cache.putAll(loaded); cache.putAll(loaded);
return cache; return cache;
} }
/**
* 解析计量单位编码:优先取快照运费计算单位(快照表 price_unit,随结算自动带出),
* 为空(历史快照)时兜底数量单位。计量单位档案暂无编码字段,暂以单位名称作为编码;
* 档案补充编码字段后在此处增加档案映射。
*
* @param priceUnit 快照运费计算单位(可能为空)
* @param fallbackUnit 兜底数量单位
* @return 计量单位编码,无法解析时返回 null(应收单同步组件复用)
*/
public String resolveMeasureunitNumber(String priceUnit, String fallbackUnit) {
String unitName = Func.isNotBlank(priceUnit) ? priceUnit : fallbackUnit;
return Func.isBlank(unitName) ? null : unitName.trim();
}
/**
* 货物类型支持“、/,”分隔的多类型写法,解析为类型名称列表;
* 分录不按类型拆行(业务约定整行一条),物料编码仅取列表第一种类型(应收单同步组件复用)
*/
public List<String> splitCargoTypes(String cargoType) {
if (Func.isBlank(cargoType)) {
throw new ServiceException("推送金蝶财务应付单失败:结算明细货物类型为空,无法解析物料编码");
}
return Arrays.stream(cargoType.split("[、,,]"))
.map(String::trim).filter(Func::isNotBlank).distinct().toList();
}
// ==================== 金蝶公共校验与档案取数 ==================== // ==================== 金蝶公共校验与档案取数 ====================
/** /**
@@ -541,6 +740,27 @@ public class KingdeeApBillSyncSupport {
return contract; return contract;
} }
/** 校验并获取结算明细所属正式结算单(批量同步时按结算单id缓存复用;应收单同步组件复用) */
public FormalSettlement requireSettlement(Long settlementId, KingdeeSyncContext context) {
FormalSettlement settlement = context.settlementCache().computeIfAbsent(settlementId, formalSettlementMapper::selectById);
if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) {
throw new ServiceException("推送金蝶失败:正式结算单[" + settlementId + "]不存在");
}
return settlement;
}
/** 校验并获取结算单关联项目(项目业务类型/业务模式决定物料映射;应收单同步组件复用) */
public ProjectApply requireProject(Long projectId, KingdeeSyncContext context) {
if (projectId == null) {
throw new ServiceException("推送金蝶失败:结算单未关联项目,无法按项目业务类型/业务模式匹配物料");
}
ProjectApply project = context.projectCache().computeIfAbsent(projectId, projectApplyMapper::selectById);
if (project == null) {
throw new ServiceException("推送金蝶失败:项目[" + projectId + "]不存在,无法匹配物料");
}
return project;
}
/** /**
* 金蝶接口日期文本:yyyy-MM-dd HH:mm:ss(A102-2/A103-1/A103-3 各接口统一格式) * 金蝶接口日期文本:yyyy-MM-dd HH:mm:ss(A102-2/A103-1/A103-3 各接口统一格式)
* *
@@ -630,45 +850,37 @@ public class KingdeeApBillSyncSupport {
} }
/** /**
* 费用行含税金额取数链:优先调整后总金额(after_amount),未维护(null/0)回退原总金额(original_amount), * 分录单价反算:结算金额/数量(保持单价×数量=金额勾稽一致);数量无效时返回 0
* 仍取不到按运输单价×运输量兜底(单据头 isincludetax=true 录入含税价,单价按含税口径相乘); * (应付/应收/收票单分录共用,应收单同步组件复用)
* 全部无法取值时拦截报错,避免分录价税合计送 0 被金蝶校验拦截("整单'价税合计'不能为空"),
* 应付/应收/收票单分录共用(应收单同步组件复用)
* *
* @param payableFee 应收应付货物费用行 * @param amount 该行结算金额(含税)
* @param sourceDesc 来源行描述,用于异常提示 * @param quantity 该行分录数量
* @return 该行含税金额(保留 2 位小数) * @return 反算单价(保留 2 位小数)
*/ */
public BigDecimal resolveFeeAmount(ReceivablePayableCargoFee payableFee, String sourceDesc) { public BigDecimal entryPrice(BigDecimal amount, BigDecimal quantity) {
BigDecimal amount = payableFee.getAfterAmount(); if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) { return BigDecimal.ZERO;
amount = payableFee.getOriginalAmount();
} }
if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) { return money(amount).divide(quantity, 2, RoundingMode.HALF_UP);
amount = money(payableFee.getUnitPrice()).multiply(money(payableFee.getTransportQuantity()));
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("推送金蝶失败:" + sourceDesc
+ "未维护费用金额(调整后总金额/原总金额/单价×数量均无法取值),无法组装分录价税合计,请先维护费用数据");
}
return money(amount);
} }
/** /**
* 财务应付单/付款申请分录:每条来源费用行一条(不聚合) * 财务应付单/付款申请分录:结算明细行 + 结算合计费用项 各一条(不聚合)
* *
* @param materialNumber 物料编码(货物类型档案货物编码) * @param materialNumber 物料编码(按项目业务类型/业务模式/装卸大类映射)
* @param measureunitNumber 计量单位编码(运费计算单位,档案暂无编码字段暂传单位名称) * @param measureunitNumber 计量单位编码(费用类型匹配合同计费方案计费单位,再匹配计量单位档案)
* @param entryCode 分录唯一标识(awrp_entry_code):费用明细行id,雪花id天然唯一 * @param entryCode 分录唯一标识(awrp_entry_code):结算明细行取单据号,结算合计费用项行取结算单号-费用类型-费用项
* @param quantity 该行运输数量 * @param quantity 该行分录数量(运费费用项目取运输量,其它费用项目默认 1)
* @param price 该行运输单价(费用明细 unit_price) * @param price 该行分录单价(结算金额/数量反算)
* @param amount 该行金额(含税),对应明细应付金额 e_pricetaxtotal * @param amount 该行金额(含税),对应明细应付金额 e_pricetaxtotal
* @param amountNoTax 该行金额(不含税),对应明细金额 e_amount * @param amountNoTax 该行金额(不含税),对应明细金额 e_amount;结算合计费用项行无不含税金额为 null 不传
* @param transportGoods 运输货物(金蝶接口新字段 awrp_transport):结算明细货物名称,结算合计费用项行为 null 不传
* @param vehicleNo 车号(金蝶接口新字段 awrp_lpn):结算明细车号,结算合计费用项行为 null 不传
* @param formalSettlementId 分录所属正式结算单id(多结算单付款申请 A103-1 按结算单勾稽各自应付单) * @param formalSettlementId 分录所属正式结算单id(多结算单付款申请 A103-1 按结算单勾稽各自应付单)
*/ */
public record BfiEntryGroup(String materialNumber, String measureunitNumber, String entryCode, public record BfiEntryGroup(String materialNumber, String measureunitNumber, String entryCode,
BigDecimal quantity, BigDecimal price, BigDecimal amount, BigDecimal amountNoTax, BigDecimal quantity, BigDecimal price, BigDecimal amount, BigDecimal amountNoTax,
Long formalSettlementId) { String transportGoods, String vehicleNo, Long formalSettlementId) {
} }
/** /**
@@ -35,7 +35,6 @@ import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail; import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee;
import org.springblade.transport.service.KingdeeSyncContext; import org.springblade.transport.service.KingdeeSyncContext;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -118,18 +117,17 @@ public class KingdeeArBillSyncSupport {
* 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。 * 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。
* 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时 * 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。 * 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
* 明细分录逐应收货物费用行生成(不聚合),含税金额取应收货物费用行调整后总金额、不含税取结算货物费用行不含税; * 明细分录与应付单(A102-2)同源同口径:页面结算明细(每行一条)+ 结算合计费用项(每行一条);
* 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成 * 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成
* *
* @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接) * @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接)
* @param contractId 正式结算单关联合同id * @param settlement 正式结算单(合同/付款方取数来源)
* @param payerName 结算单付款方(客户名称),往来户取数来源 * @param entryGroups 结算明细+结算合计费用项解析出的应收单分录
* @param entryGroups 结算明细解析出的应收单分录
* @param subject 业务单据号(正式结算单号,日志与异常提示用) * @param subject 业务单据号(正式结算单号,日志与异常提示用)
* @param context 金蝶同步上下文(缓存载体,预检命中与应付共用防重键缓存) * @param context 金蝶同步上下文(缓存载体,预检命中与应付共用防重键缓存)
* @return 财务应收单推送结果(单据编号 + 是否金蝶已存在幂等跳过) * @return 财务应收单推送结果(单据编号 + 是否金蝶已存在幂等跳过)
*/ */
public KingdeeApBillSyncSupport.BfiApBillPushOutcome pushArBill(String arBillSrcCode, Long contractId, String payerName, public KingdeeApBillSyncSupport.BfiApBillPushOutcome pushArBill(String arBillSrcCode, FormalSettlement settlement,
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups, List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups,
String subject, KingdeeSyncContext context) { String subject, KingdeeSyncContext context) {
// A302-4 幂等预检:按防重键查询金蝶是否已生成财务应收单,已存在则幂等跳过保存,直接返回已有单据编号 // A302-4 幂等预检:按防重键查询金蝶是否已生成财务应收单,已存在则幂等跳过保存,直接返回已有单据编号
@@ -139,7 +137,7 @@ public class KingdeeArBillSyncSupport {
log.info("正式结算单[{}]金蝶财务应收单已生成(单据编号[{}]),幂等跳过 A302-2 保存", subject, existBillNo); log.info("正式结算单[{}]金蝶财务应收单已生成(单据编号[{}]),幂等跳过 A302-2 保存", subject, existBillNo);
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, true); return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, true);
} }
ContractManage contract = kingdeeApBillSyncSupport.requireContract(contractId, context); ContractManage contract = kingdeeApBillSyncSupport.requireContract(settlement.getContractId(), context);
// 单据类型按映射字典转换:默认服务销售应收,金蝶确认其他类型后修改 BFI_AR_BILL_BILLTYPE_NAME 即可 // 单据类型按映射字典转换:默认服务销售应收,金蝶确认其他类型后修改 BFI_AR_BILL_BILLTYPE_NAME 即可
String billtypeNumber = KingdeeBfiConstant.billtypeBy(KingdeeBfiConstant.AR_BILL_BILLTYPE_MAP, KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME); String billtypeNumber = KingdeeBfiConstant.billtypeBy(KingdeeBfiConstant.AR_BILL_BILLTYPE_MAP, KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME);
if (Func.isBlank(billtypeNumber)) { if (Func.isBlank(billtypeNumber)) {
@@ -151,6 +149,7 @@ public class KingdeeArBillSyncSupport {
// 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),以同步当日作为应收单据日期 // 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),以同步当日作为应收单据日期
String billDateText = kingdeeApBillSyncSupport.bfiDateTimeText(LocalDate.now()); String billDateText = kingdeeApBillSyncSupport.bfiDateTimeText(LocalDate.now());
// 往来户.编码:应收结算单付款方(客户)关联客商档案客商编码 // 往来户.编码:应收结算单付款方(客户)关联客商档案客商编码
String payerName = settlement.getPayerName();
if (Func.isBlank(payerName)) { if (Func.isBlank(payerName)) {
throw new ServiceException("推送金蝶财务应收单失败,结算单付款方不能为空,请检查正式结算单[" + Func.toStr(subject) + "]付款方信息"); throw new ServiceException("推送金蝶财务应收单失败,结算单付款方不能为空,请检查正式结算单[" + Func.toStr(subject) + "]付款方信息");
} }
@@ -190,129 +189,105 @@ public class KingdeeArBillSyncSupport {
// 【必填】外部单据类型 // 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_AR_BILL_SRC_TYPE); param.setAwrpSrcType(KingdeeBfiConstant.BFI_AR_BILL_SRC_TYPE);
// 明细分录:每条应收货物费用行一条(不聚合),无吨位时数量兜底 1 // 汇率日期:正式结算单页面维护的汇率日期(非必传,未维护不送)
if (settlement.getExchangeRateDate() != null) {
param.setExratedate(kingdeeApBillSyncSupport.bfiDateTimeText(settlement.getExchangeRateDate()));
}
// 汇率:正式结算单页面维护的结算汇率(非必传,未维护不送)
if (settlement.getExchangeRate() != null) {
param.setExchangerate(settlement.getExchangeRate());
}
// 明细分录:与应付单同源同口径——结算明细行 + 结算合计费用项各一条(不聚合),无吨位时数量兜底 1
List<BfiArBillSaveEntryDTO> entry = new ArrayList<>(); List<BfiArBillSaveEntryDTO> entry = new ArrayList<>();
for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) { for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) {
BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO(); BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO();
// 【必填】物料编码.编码:结算明细货物类型匹配货物类型档案货物编码
row.setEMaterialNumber(entryGroup.materialNumber()); String materialNumber = entryGroup.materialNumber();
// 【必填】计量单位.编码:货物费用行运费计算单位(快照 price_unit,档案暂无编码字段暂传单位名称)
// 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(与应付单同源)
row.setEMaterialNumber(materialNumber);
// 【必填】计量单位.编码:费用类型匹配合同计费方案计费单位,再匹配计量单位档案编码
row.setEMeasureunitNumber(entryGroup.measureunitNumber()); row.setEMeasureunitNumber(entryGroup.measureunitNumber());
// 【必填】数量:该行运输数量,无吨位数据时兜底 1 // 【必填】数量:运费费用项目取运输量,其它费用项目默认 1
row.setEQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); row.setEQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
// 明细.单价:该行运输单价(费用明细 unit_price;单据头 ispricetotal=true 录入总价,单价仅作参考) // 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传
row.setAwrpTransport(entryGroup.transportGoods());
// 车号(金蝶接口新字段):结算明细车号,结算合计费用项行不传
row.setAwrpLpn(entryGroup.vehicleNo());
// 明细.单价:结算金额/数量反算(单据头 ispricetotal=true 录入总价,单价仅作参考)
row.setEUnitprice(entryGroup.price()); row.setEUnitprice(entryGroup.price());
// 应收金额(含税):该行应收货物费用行调整后总金额(含税) // 应收金额(含税):结算明细行结算金额(含税)/结算合计费用项行结算金额
row.setERecamount(entryGroup.amount()); row.setERecamount(entryGroup.amount());
// 金额(不含税):该行结算金额(不含税)(应收费用行无不含税字段,经结算货物费用行回链) // 金额(不含税):结算明细行结算金额(不含税),结算合计费用项行无不含税金额不传
row.setEAmount(entryGroup.amountNoTax()); row.setEAmount(entryGroup.amountNoTax());
// 【必填】项目.项目编码(联调期临时传金蝶测试档案值) // 【必填】项目.项目编码(联调期临时传金蝶测试档案值)
row.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST); 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.setAwrpEntryCode(entryGroup.entryCode());
// 分录备注(非必传) // 分录备注(非必传)
// row.setERemark(subject); // row.setERemark(subject);
// 调试:物料编码和计量单位编码映射(4个固定组合)
if (true) {
// 物料+单位组合映射,只保留【已审核】物料
Map<String, String[]> comboMap = Map.of(
"国际货运代理服务费", new String[]{"Item-00001081", "ci"},
"物流配送", new String[]{"Item-00001080", "TON"},
"国内道路货物运输服务-公路运输-吨", new String[]{"XDWL11010001", "TON"},
"国内道路货物运输服务-公路运输-公斤", new String[]{"XDWL11010002", "KG"}
);
// ⚠️兜底改成【已审核】物流配送 Item-00001080 + TON
String[] defaultCombo = comboMap.get("物流配送");
// 构建组合键:物料名称-计量单位
String measureunitName = entryGroup.measureunitNumber();
String comboKey = materialNumber;
if (materialNumber.contains("国内道路货物运输服务")) {
comboKey = materialNumber + "-" + measureunitName;
}
// 匹配组合,不匹配使用兜底组合
String[] combo = comboMap.getOrDefault(comboKey, defaultCombo);
materialNumber = combo[0];
row.setEMaterialNumber(materialNumber);
row.setEMeasureunitNumber(combo[1]);
}
entry.add(row); entry.add(row);
} }
param.setEntry(entry); param.setEntry(entry);
// 收款计划 planentity 不传:由金蝶按明细自动汇总生成(启用方案待业务确认后迭代) // 收款计划 planentity 不传:由金蝶按明细自动汇总生成(启用方案待业务确认后迭代)
log.info("推送金蝶财务应收单:正式结算单[{}],防重键[{}],明细分录{}条", subject, arBillSrcCode, entry.size());
BfiBillSaveResultVO saveResult = bfiService.saveArBill(param); BfiBillSaveResultVO saveResult = bfiService.saveArBill(param);
// 应收单id不落库,后续如需可经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询 // 应收单id不落库,后续如需可经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶财务应收单"); kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶财务应收单");
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(saveResult.getResult().get(0).getNumber(), false); String billNo = saveResult.getResult().get(0).getNumber();
log.info("金蝶财务应收单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, entry.size());
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(billNo, false);
} }
// ==================== 结算明细分录解析 ==================== // ==================== 结算明细分录解析 ====================
/** /**
* 解析正式结算明细,逐行生成应收单分录(不聚合,每条来源费用行一条分录),分录携带所属正式结算单id。 * 解析应收正式结算明细为应收单分录:与应付单(A102-2)分录解析同源同口径——
* <p>取数来源与应付单分录同源:结算明细 sourceDetailId 关联应收应付货物费用行(blade_receivable_payable_cargo_fee, * 页面结算明细每行一条 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送),
* 每货物一条)——货物类型/计量单位/运输数量/含税金额直接取费用行(含税金额取数链:调整后总金额 after_amount → * 物料/计量单位/数量/单价/金额/分录标识取数规则一致,
* 原总金额 → 单价×数量兜底,见 {@link KingdeeApBillSyncSupport#resolveFeeAmount});不含税金额经结算 * 直接复用应付支撑组件解析(应收分录不含税金额取结算明细结算金额(不含税))
* 货物费用行 sourceFeeId 回链;无货物费用行时直接报错拦截。物料编码取第一种货物类型匹配货物类型档案货物编码;
* 分录唯一标识(awrp_entry_code)取费用明细行id。</p>
* *
* @param settlementDetails 正式结算明细列表 * @param settlement 正式结算单
* @param context 金蝶同步上下文(缓存载体) * @param settlementDetails 该结算单下的结算明细
* @param context 金蝶同步上下文(缓存载体)
* @return 应收单分录列表(保持行序) * @return 应收单分录列表(保持行序)
*/ */
public List<KingdeeApBillSyncSupport.BfiEntryGroup> resolveArEntryGroups(List<FormalSettlementDetail> settlementDetails, public List<KingdeeApBillSyncSupport.BfiEntryGroup> resolveArEntryGroups(FormalSettlement settlement,
List<FormalSettlementDetail> settlementDetails,
KingdeeSyncContext context) { KingdeeSyncContext context) {
if (Func.isEmpty(settlementDetails)) { return kingdeeApBillSyncSupport.resolveEntryGroups(settlement, settlementDetails, context);
throw new ServiceException("推送金蝶财务应收单失败:正式结算单无结算明细,无法解析物料与计量单位");
}
Map<String, String> cargoCodeByName = kingdeeApBillSyncSupport.cargoCodeByName(context);
// 应收应付明细id->货物费用行(分录主取数来源,应收应付共用同一张费用行表)
Map<Long, List<ReceivablePayableCargoFee>> feesByDetailId = kingdeeApBillSyncSupport.payableCargoFeesByDetailId(settlementDetails);
// 费用行id->结算金额(不含税):经结算货物费用行 sourceFeeId 回链
Map<Long, BigDecimal> noTaxByFeeId = kingdeeApBillSyncSupport.settlementNoTaxByPayableFeeId(settlementDetails);
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups = new ArrayList<>();
for (FormalSettlementDetail settlementDetail : settlementDetails) {
List<ReceivablePayableCargoFee> fees = feesByDetailId.getOrDefault(settlementDetail.getSourceDetailId(), List.of());
if (Func.isEmpty(fees)) {
throw new ServiceException("推送金蝶财务应收单失败:结算明细[" + Func.toStr(settlementDetail.getDocumentNo())
+ "]对应应收明细无货物费用行,分录取数必须来自应收费用明细,请先维护费用数据");
}
for (ReceivablePayableCargoFee fee : fees) {
String sourceDesc = "应收货物费用[" + Func.toStr(fee.getCargoName()) + " 第" + Func.toStr(fee.getLineNo()) + "行]";
appendArEntryGroups(entryGroups, cargoCodeByName, settlementDetail.getFormalSettlementId(),
fee.getCargoType(),
kingdeeApBillSyncSupport.resolveMeasureunitNumber(fee.getPriceUnit(), fee.getQuantityUnit()),
fee.getId(), kingdeeApBillSyncSupport.money(fee.getUnitPrice()),
kingdeeApBillSyncSupport.resolveFeeAmount(fee, sourceDesc),
noTaxByFeeId.getOrDefault(fee.getId(), BigDecimal.ZERO),
kingdeeApBillSyncSupport.money(fee.getTransportQuantity()),
sourceDesc);
}
}
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<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups, Map<String, String> 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);
} }
} }
@@ -63,9 +63,9 @@ import java.util.Map;
* (审批通过、已绑定镜像、一票一应付、A102-4 应付单已生成预检、A801-3 幂等预检)后调用本组件; * (审批通过、已绑定镜像、一票一应付、A102-4 应付单已生成预检、A801-3 幂等预检)后调用本组件;
* 报文组装取数:主表组织/币种/往来户复用应付支撑组件的合同派生方法,票面字段取自票池镜像 * 报文组装取数:主表组织/币种/往来户复用应付支撑组件的合同派生方法,票面字段取自票池镜像
* (invoice_type_code 等,A801-1 落库),明细分录与财务应付单 A102-2 同源同口径 * (invoice_type_code 等,A801-1 落库),明细分录与财务应付单 A102-2 同源同口径
* (逐结算货物费用行一条,{@link KingdeeApBillSyncSupport.BfiEntryGroup},分录标识同取费用行id)。 * (结算明细行+结算合计费用项各一条,{@link KingdeeApBillSyncSupport.BfiEntryGroup},分录标识同键)。
* 防重键:收票单 awrp_src_code=收票登记id,awrp_entry_code=应付货物费用行id(与应付单分录同键, * 防重键:收票单 awrp_src_code=收票登记id,awrp_entry_code=结算明细行单据号/结算合计费用项拼接
* 便于金蝶侧逐行勾稽);A302-6 按「收票单防重键 ↔ 财务应付单防重键」一票一应付指定。 * (与应付单分录同键,便于金蝶侧逐行勾稽);A302-6 按「收票单防重键 ↔ 财务应付单防重键」一票一应付指定。
* 财务应付单侧(A102-2/A102-4)取数与校验复用 {@link KingdeeApBillSyncSupport}。</p> * 财务应付单侧(A102-2/A102-4)取数与校验复用 {@link KingdeeApBillSyncSupport}。</p>
* *
* @author Chill * @author Chill
@@ -148,15 +148,15 @@ public class KingdeeReceivedInvoiceSyncSupport {
/** /**
* 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。 * 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。
* 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位; * 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位;
* 明细分录与财务应付单 A102-2 同源同口径:逐结算货物费用行一条(不聚合),物料/数量/单价/金额取自 * 明细分录与财务应付单 A102-2 同源同口径:结算明细行+结算合计费用项各一条(不聚合),物料/数量/单价/金额取自
* 与应付单分录同一解析结果({@link KingdeeApBillSyncSupport.BfiEntryGroup}),分录标识同取费用行id。 * 与应付单分录同一解析结果({@link KingdeeApBillSyncSupport.BfiEntryGroup}),分录标识同键。
* 联调口径沿用应付侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码), * 联调口径沿用应付侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码),
* 非必填的税号/地址/电话/开户行等 setter 注释保留,联调确认后放开 * 非必填的税号/地址/电话/开户行等 setter 注释保留,联调确认后放开
* *
* @param receipt 收票登记(审批通过、已绑定镜像) * @param receipt 收票登记(审批通过、已绑定镜像)
* @param mirror 票池镜像(票面主表字段取数来源) * @param mirror 票池镜像(票面主表字段取数来源)
* @param contract 分摊应付结算单关联合同(组织/币种/往来户派生) * @param contract 分摊应付结算单关联合同(组织/币种/往来户派生)
* @param entryGroups 分摊结算单解析出的货物费用行分录(与应付单 A102-2 同一解析结果) * @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果)
* @param context 金蝶同步上下文(缓存载体) * @param context 金蝶同步上下文(缓存载体)
* @return 金蝶收票单单据编号 * @return 金蝶收票单单据编号
*/ */
@@ -211,7 +211,7 @@ public class KingdeeReceivedInvoiceSyncSupport {
// param.setRemark(receipt.getRemark()); // param.setRemark(receipt.getRemark());
// 【必填】外部单据类型 // 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_RECEIVED_INVOICE_SRC_TYPE); param.setAwrpSrcType(KingdeeBfiConstant.BFI_RECEIVED_INVOICE_SRC_TYPE);
// 【必填】明细分录:与应付单 A102-2 同源同口径,逐结算货物费用行一条(不聚合) // 【必填】明细分录:与应付单 A102-2 同源同口径,结算明细行+结算合计费用项各一条(不聚合)
param.setEntry(buildEntries(entryGroups)); param.setEntry(buildEntries(entryGroups));
BfiBillSaveResultVO saveResult = bfiService.saveReceivedInvoice(param); BfiBillSaveResultVO saveResult = bfiService.saveReceivedInvoice(param);
@@ -220,12 +220,12 @@ public class KingdeeReceivedInvoiceSyncSupport {
} }
/** /**
* 分录组装与应付单 A102-2 明细同源同口径:逐结算货物费用行一条(不聚合),物料/数量/单价/含税/不含税金额 * 分录组装与应付单 A102-2 明细同源同口径:结算明细行+结算合计费用项各一条(不聚合),物料/数量/单价/含税/不含税金额
* 取自 {@link KingdeeApBillSyncSupport.BfiEntryGroup}(与应付单分录同一解析结果),分录标识同取费用行id, * 取自 {@link KingdeeApBillSyncSupport.BfiEntryGroup}(与应付单分录同一解析结果),分录标识同键,
* 便于金蝶侧收票单明细与应付单明细逐行勾稽;数量无吨位时兜底 1; * 便于金蝶侧收票单明细与应付单明细逐行勾稽;数量无效时兜底 1;
* 票面明细行(items_json)不再作为分录取数来源,税收分类/税率/税额随应付口径暂不送(联调强校验时再补) * 票面明细行(items_json)不再作为分录取数来源,税收分类/税率/税额随应付口径暂不送(联调强校验时再补)
* *
* @param entryGroups 分摊结算单解析出的货物费用行分录(与应付单 A102-2 同一解析结果) * @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果)
* @return A303-2 明细分录 * @return A303-2 明细分录
*/ */
private List<BfiReceivedInvoiceSaveEntryDTO> buildEntries(List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups) { private List<BfiReceivedInvoiceSaveEntryDTO> buildEntries(List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups) {
@@ -238,24 +238,19 @@ public class KingdeeReceivedInvoiceSyncSupport {
entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
// 【必填】单价:该行运输单价(与应付单同源;单据头 isincludetax=true 录入价税合计,单价仅作参考) // 【必填】单价:该行运输单价(与应付单同源;单据头 isincludetax=true 录入价税合计,单价仅作参考)
entry.setPrice(entryGroup.price()); entry.setPrice(entryGroup.price());
// 【必填】物料编码.编码:结算明细货物类型匹配货物类型档案货物编码 // 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(与应付单同源)
entry.setMaterialNumber(entryGroup.materialNumber()); entry.setMaterialNumber(entryGroup.materialNumber());
// 金额(不含税):该行结算金额(不含税)(与应付单 e_amount 同源取数) // 金额(不含税):该行结算金额(不含税)(与应付单 e_amount 同源取数;结算合计费用项行无不含税金额不传)
entry.setEAmount(kingdeeApBillSyncSupport.money(entryGroup.amountNoTax())); if (entryGroup.amountNoTax() != null) {
// 【必填】外部系统来源分录标识(候选键):应付货物费用行id(与应付单分录同键,便于逐行勾稽) entry.setEAmount(kingdeeApBillSyncSupport.money(entryGroup.amountNoTax()));
entry.setAwrpEntryCode(entryGroup.entryCode());
// 调试(与应付单 A102-2 保持一致:联调期物料编码临时传金蝶测试档案值,联调通过后与应付侧同步移除)
if (true) {
// 【必填】物料编码.编码:结算明细货物类型匹配货物类型档案货物编码
entry.setMaterialNumber(KingdeeBfiConstant.BFI_AP_BILL_MATERIAL_NUMBER_TEST);
} }
// 【必填】外部系统来源分录标识(候选键):与应付单分录同键(结算明细行单据号/结算合计费用项拼接),便于逐行勾稽
entry.setAwrpEntryCode(entryGroup.entryCode());
entries.add(entry); entries.add(entry);
} }
if (entries.isEmpty()) { if (entries.isEmpty()) {
throw new ServiceException("推送金蝶收票单失败:分摊结算单无货物费用行,无法组装收票单明细"); throw new ServiceException("推送金蝶收票单失败:分摊结算单无结算明细与结算合计费用项,无法组装收票单明细");
} }
return entries; return entries;
} }