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 b1c45f44..055be3f3 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 @@ -186,6 +186,9 @@ public final class KingdeeBfiConstant { /** A303-2 业务类型(PUR:采购 / FEE:费用,TMS 服务费进项票暂按采购)。待确认:取值需金蝶侧最终确认。 */ public static final String BFI_RECEIVED_BIZ_TYPE = "PUR"; + /** A303-2 结算币别:收票登记不挂合同(先票后款),数电票固定人民币。 */ + public static final String BFI_RECEIVED_CURRENCY_NUMBER = "CNY"; + /** A303-2 必填:外部单据类型(收票单来源标识)。待确认:取值需金蝶侧确认,确认后如有调整直接改此常量。 */ public static final String BFI_RECEIVED_INVOICE_SRC_TYPE = "TMS收票登记"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java index 48a74c14..72e6cb7b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java @@ -28,6 +28,7 @@ package org.springblade.transport.support; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.Func; import org.springblade.thirdparty.bfi.constant.BfiConstant; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignItemDTO; @@ -37,22 +38,24 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolItemVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.thirdparty.bfi.util.BfiUtil; 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.InvoiceReceipt; import org.springblade.transport.pojo.entity.KingdeeInvoicePool; import org.springblade.transport.service.KingdeeSyncContext; import org.springframework.stereotype.Component; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; @@ -61,12 +64,11 @@ import java.util.Map; /** * 金蝶收票单同步支撑组件(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付 + A801-3 收票单查询)。 *

收票登记「推送金蝶」由 {@link org.springblade.transport.service.IInvoiceReceiptService} 编排前置校验 - * (审批通过、已绑定镜像、一票一应付、A102-4 应付单已生成预检、A801-3 幂等预检)后调用本组件; - * 报文组装取数:主表组织/币种/往来户复用应付支撑组件的合同派生方法,票面字段取自票池镜像 - * (invoice_type_code 等,A801-1 落库),明细分录与财务应付单 A102-2 同源同口径 - * (结算明细行+结算合计费用项各一条,{@link KingdeeApBillSyncSupport.BfiEntryGroup},分录标识同键)。 - * 防重键:收票单 awrp_src_code=收票登记id,awrp_entry_code=结算明细行单据号/结算合计费用项拼接 - * (与应付单分录同键,便于金蝶侧逐行勾稽);A302-6 按「收票单防重键 ↔ 财务应付单防重键」一票一应付指定。 + * (审批通过、已绑定镜像、A801-3 幂等预检)后调用本组件;先票后款链路:A303-2 推送即终态(pushed), + * A302-6 指定应付移交付款结算单侧(应付单 A102-2 生成后按认领关系逐单指定)。 + * 报文组装取数:往来户=发票销售方金蝶供应商代码(A901-1),结算组织=页面手选部门沿部门树上溯平台公司, + * 币别固定 CNY;票面字段取自票池镜像(A801-1 落库),明细分录逐行取发票明细(镜像 items_json), + * 分录标识 awrp_entry_code=发票号码_seq(重推幂等候选键)。 * 财务应付单侧(A102-2/A102-4)取数与校验复用 {@link KingdeeApBillSyncSupport}。

* * @author Chill @@ -148,21 +150,19 @@ public class KingdeeReceivedInvoiceSyncSupport { /** * 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。 - * 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位; - * 明细分录与财务应付单 A102-2 同源同口径:结算明细按 货物→费用大类→税率 拆分行+手动添加结算合计费用项每行一条,物料/数量/单价/金额取自 - * 与应付单分录同一解析结果({@link KingdeeApBillSyncSupport.BfiEntryGroup}),分录标识同键。 + * 先票后款链路:收票登记不挂结算单/合同——主表往来户=发票销售方金蝶供应商代码(receipt.supplierCode, + * 缺失实时查 A901-1 一次,仍缺失报错阻断),结算组织=页面手选部门(receipt.deptId)沿部门树反查平台公司, + * 币别固定 CNY,往来类型取固定常量;销方=开票单位、购方=受票单位。 + * 明细分录逐行取发票明细行(镜像 items_json,{@link BfiInvoicePoolItemVO}),不与结算明细同源。 * 联调口径沿用应付侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码), * 非必填的税号/地址/电话/开户行等 setter 注释保留,联调确认后放开 * - * @param receipt 收票登记(审批通过、已绑定镜像) - * @param mirror 票池镜像(票面主表字段取数来源) - * @param contract 分摊应付结算单关联合同(组织/币种/往来户派生) - * @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果) - * @param context 金蝶同步上下文(缓存载体) + * @param receipt 收票登记(审批通过、已绑定镜像) + * @param mirror 票池镜像(票面主表字段+明细行取数来源) + * @param context 金蝶同步上下文(缓存载体) * @return 推送结果(单据编号 + 星瀚收票单内码,A902 附件上传定位用) */ - public ReceivedInvoicePushOutcome pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract, - List entryGroups, KingdeeSyncContext context) { + public ReceivedInvoicePushOutcome pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, KingdeeSyncContext context) { if (Func.isBlank(mirror.getInvoiceTypeCode())) { throw new ServiceException("推送金蝶收票单失败:镜像缺少票易通发票类型编码,请先重新绑定票池发票"); } @@ -183,14 +183,17 @@ public class KingdeeReceivedInvoiceSyncSupport { param.setInvoicetypef7Number(mirror.getInvoiceTypeCode()); // 【必填】付款方式:(与应付单一致,不允许金蝶自动推付款) param.setPaymenttype(BfiConstant.PAY_MODE_CASH); - // 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径) - param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract)); - // 【必填】结算组织.编码:合同关联项目业务部门反查平台公司(与应付单同源) - param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract, context)); - // 【必填】结算币别:合同结算币种 - param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract)); - // 【必填】往来户.编码:合同乙方(开票供应商)客商编码 - param.setReceivablessuppNumber(kingdeeApBillSyncSupport.bfiAsstactNumber(contract, context)); + // 【必填】往来类型:固定常量(收票登记不挂合同,直接取常量) + param.setAsstacttype(KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE); + // 【必填】结算组织.编码:页面手选部门沿部门树反查平台公司(收票登记 deptId) + param.setOrgNumber(kingdeeApBillSyncSupport.kingdeeOrgNumberByDept(receipt.getDeptId(), context)); + // 【必填】结算币别:固定 CNY(收票登记不挂合同,数电票人民币) + param.setCurrencyNumber(KingdeeBfiConstant.BFI_RECEIVED_CURRENCY_NUMBER); + // 【必填】往来户.编码:发票销售方金蝶供应商代码(缺失实时查 A901-1,仍缺失报错阻断) + String supplierNumber = this.requireSupplierNumber(receipt, mirror); + param.setReceivablessuppNumber(supplierNumber); + // 销售方名称.编码(非必填):同往来户供应商代码 + param.setAsstactNumber(supplierNumber); // 【必填】汇率表.编码 param.setExratetableNumber(BfiConstant.EXRATE_TABLE_DEFAULT); // 【必填】销方名称:镜像开票单位 @@ -212,8 +215,8 @@ public class KingdeeReceivedInvoiceSyncSupport { // param.setRemark(receipt.getRemark()); // 【必填】外部单据类型 param.setAwrpSrcType(KingdeeBfiConstant.BFI_RECEIVED_INVOICE_SRC_TYPE); - // 【必填】明细分录:与应付单 A102-2 同源同口径,结算明细行+结算合计费用项各一条(不聚合) - param.setEntry(buildEntries(entryGroups)); + // 【必填】明细分录:逐行取发票明细行(镜像 items_json),不与结算明细同源 + param.setEntry(this.buildEntries(receipt, mirror)); BfiBillSaveResultVO saveResult = bfiService.saveReceivedInvoice(param); kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶收票单"); @@ -222,6 +225,30 @@ public class KingdeeReceivedInvoiceSyncSupport { savedItem.getId() == null ? null : String.valueOf(savedItem.getId())); } + /** + * 往来户供应商编码:优先取登记上已回填的 supplierCode;缺失时按销方名称实时查一次 A901-1 并回填实体 + * (调用方推送成功后随推送结果落库),仍缺失报错阻断 + * + * @param receipt 收票登记 + * @param mirror 票池镜像(销方名称=开票单位) + * @return 金蝶供应商编码 + */ + private String requireSupplierNumber(InvoiceReceipt receipt, KingdeeInvoicePool mirror) { + String supplierCode = receipt.getSupplierCode(); + if (Func.isBlank(supplierCode)) { + log.info("收票登记[{}]往来户供应商代码缺失,按销方[{}]实时查询 A901-1", receipt.getInvoiceNo(), mirror.getIssuerName()); + supplierCode = kingdeeApBillSyncSupport.queryCustomerCode(mirror.getIssuerName()); + if (Func.isNotBlank(supplierCode)) { + receipt.setSupplierCode(supplierCode); + } + } + if (Func.isBlank(supplierCode)) { + throw new ServiceException("推送金蝶收票单失败:发票销售方[" + Func.toStr(mirror.getIssuerName()) + + "]未建档金蝶供应商编码,请先维护客商档案后再推送"); + } + return supplierCode; + } + /** * A303-2 收票单推送结果 * @@ -232,42 +259,139 @@ public class KingdeeReceivedInvoiceSyncSupport { } /** - * 分录组装与应付单 A102-2 明细同源同口径:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条, - * 物料/数量/单价/含税/不含税金额 - * 取自 {@link KingdeeApBillSyncSupport.BfiEntryGroup}(与应付单分录同一解析结果),分录标识同键, - * 便于金蝶侧收票单明细与应付单明细逐行勾稽;数量无效时兜底 1; - * 票面明细行(items_json)不再作为分录取数来源,税收分类/税率/税额随应付口径暂不送(联调强校验时再补) + * 分录组装:逐行取发票明细行(镜像 items_json 反序列化为 {@link BfiInvoicePoolItemVO}),一条明细行一条分录: + * 价税合计=明细金额+税额(合计为 0 拦截);数量取 num(≤0/空/不可解析兜底 1);单价取 unitPrice(空按价税合计/数量反算); + * 物料编码与税收分类编码取行商品编码 goodsCode(缺失拦截);税率表编码="V"+整数税率(13/13%/0.13→V13, + * 缺失、0/免税行拦截提示人工);不含税金额/税额取明细金额/税额(空不送);分录标识=发票号码_seq(重推幂等候选键) * - * @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果) + * @param receipt 收票登记(发票号码,分录标识前缀) + * @param mirror 票池镜像(明细行 items_json) * @return A303-2 明细分录 */ - private List buildEntries(List entryGroups) { + private List buildEntries(InvoiceReceipt receipt, KingdeeInvoicePool mirror) { + if (Func.isBlank(mirror.getItemsJson())) { + throw new ServiceException("推送金蝶收票单失败:发票[" + receipt.getInvoiceNo() + "]票池镜像无明细行,请先按票号补查票池明细"); + } + List items = this.parseMirrorItems(receipt.getInvoiceNo(), mirror.getItemsJson()); + if (Func.isEmpty(items)) { + throw new ServiceException("推送金蝶收票单失败:发票[" + receipt.getInvoiceNo() + "]票池镜像明细行为空,请先按票号补查票池明细"); + } List entries = new ArrayList<>(); - for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) { - BfiReceivedInvoiceSaveEntryDTO entry = new BfiReceivedInvoiceSaveEntryDTO(); - // 【必填】价税合计:该行含税金额(与应付单 e_pricetaxtotal 同源取数) - entry.setEPricetaxtotal(kingdeeApBillSyncSupport.money(entryGroup.amount())); - // 【必填】数量:该行运输数量,无吨位数据时兜底 1(与应付单口径一致) - entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); - // 【必填】单价:该行运输单价(与应付单同源;单据头 isincludetax=true 录入价税合计,单价仅作参考) - entry.setPrice(entryGroup.price()); - // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置,与应付单同源) - entry.setMaterialNumber(entryGroup.materialNumber()); - // 金额(不含税):该行结算金额(不含税)(与应付单 e_amount 同源取数;结算合计费用项行无不含税金额不传) - if (entryGroup.amountNoTax() != null) { - entry.setEAmount(kingdeeApBillSyncSupport.money(entryGroup.amountNoTax())); + int rowNo = 0; + for (BfiInvoicePoolItemVO item : items) { + rowNo++; + // 分录序号:优先取明细行 seq,缺失按行号兜底 + String seq = Func.isBlank(item.getSeq()) ? String.valueOf(rowNo) : item.getSeq().trim(); + BigDecimal detailAmount = this.parseMirrorAmount(receipt.getInvoiceNo(), seq, "明细金额", item.getDetailAmount()); + BigDecimal taxAmount = this.parseMirrorAmount(receipt.getInvoiceNo(), seq, "税额", item.getTaxAmount()); + // 【必填】价税合计:明细金额+税额(合计为 0 拦截) + BigDecimal priceTaxTotal = kingdeeApBillSyncSupport.money(detailAmount == null ? BigDecimal.ZERO : detailAmount) + .add(kingdeeApBillSyncSupport.money(taxAmount == null ? BigDecimal.ZERO : taxAmount)); + if (priceTaxTotal.compareTo(BigDecimal.ZERO) == 0) { + throw new ServiceException("推送金蝶收票单失败:发票[" + receipt.getInvoiceNo() + "]分录[" + seq + "]价税合计为 0,无法推送"); } - // 【必填】外部系统来源分录标识(候选键):与应付单分录同键(结算明细行单据号/结算合计费用项拼接),便于逐行勾稽 - entry.setAwrpEntryCode(entryGroup.entryCode()); - + if (Func.isBlank(item.getGoodsCode())) { + throw new ServiceException("推送金蝶收票单失败:发票[" + receipt.getInvoiceNo() + "]分录[" + seq + "]缺少行商品编码,无法确定物料与税收分类编码"); + } + BfiReceivedInvoiceSaveEntryDTO entry = new BfiReceivedInvoiceSaveEntryDTO(); + entry.setEPricetaxtotal(priceTaxTotal); + // 【必填】数量:明细行数量(≤0/空/不可解析兜底 1) + entry.setQuantity(this.mirrorQuantity(item.getNum())); + // 【必填】单价:明细行单价(空按价税合计/数量反算;单据头 isincludetax=true,单价仅作参考) + entry.setPrice(this.mirrorPrice(item.getUnitPrice(), priceTaxTotal, entry.getQuantity())); + // 【必填】物料编码.编码:行商品编码 + entry.setMaterialNumber(item.getGoodsCode().trim()); + // 税收分类编码.编码:数电票行商品编码即税收分类编码 + entry.setETaxclassNumber(item.getGoodsCode().trim()); + // 【必填】税率表.编码:"V"+整数税率(缺失、0/免税行拦截提示人工) + entry.setTaxrateidNumber(this.taxRateCode(receipt.getInvoiceNo(), seq, item.getTaxRate())); + // 金额(不含税)/税额:明细金额/税额(空不送) + if (detailAmount != null) { + entry.setEAmount(kingdeeApBillSyncSupport.money(detailAmount)); + } + if (taxAmount != null) { + entry.setETax(kingdeeApBillSyncSupport.money(taxAmount)); + } + // 【必填】外部系统来源分录标识(候选键):发票号码_seq,重推同键幂等 + entry.setAwrpEntryCode(receipt.getInvoiceNo() + "_" + seq); entries.add(entry); } - if (entries.isEmpty()) { - throw new ServiceException("推送金蝶收票单失败:分摊结算单无结算明细与结算合计费用项,无法组装收票单明细"); - } return entries; } + /** 镜像明细行反序列化:items_json 解析异常按脏数据拦截(A801-1 原样落库,理论上可解析) */ + private List parseMirrorItems(String invoiceNo, String itemsJson) { + try { + return Arrays.asList(JsonUtil.parse(itemsJson, BfiInvoicePoolItemVO[].class)); + } catch (RuntimeException exception) { + log.warn("收票登记[{}]票池镜像明细行反序列化失败:{}", invoiceNo, exception.getMessage()); + throw new ServiceException("推送金蝶收票单失败:发票[" + invoiceNo + "]票池镜像明细行数据异常,请先按票号补查票池明细"); + } + } + + /** 明细行数量:num ≤0/空/不可解析兜底 1 */ + private BigDecimal mirrorQuantity(String num) { + BigDecimal quantity = this.parseMirrorAmountQuietly(num); + if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ONE; + } + return quantity; + } + + /** 明细行单价:unitPrice 空/不可解析按 价税合计/数量 反算(2 位小数) */ + private BigDecimal mirrorPrice(String unitPrice, BigDecimal priceTaxTotal, BigDecimal quantity) { + BigDecimal price = this.parseMirrorAmountQuietly(unitPrice); + if (price != null && price.compareTo(BigDecimal.ZERO) > 0) { + return price; + } + return priceTaxTotal.divide(quantity, 2, RoundingMode.HALF_UP); + } + + /** 镜像税率转税率表编码:"V"+整数税率(13/13%/0.13→V13);缺失、零/免税行拦截提示人工 */ + private String taxRateCode(String invoiceNo, String seq, String taxRateText) { + if (Func.isBlank(taxRateText)) { + throw new ServiceException("推送金蝶收票单失败:发票[" + invoiceNo + "]分录[" + seq + "]税率缺失,无法映射税率表,请人工核实"); + } + BigDecimal rate; + try { + rate = new BigDecimal(taxRateText.trim().replace("%", "")); + } catch (NumberFormatException exception) { + throw new ServiceException("推送金蝶收票单失败:发票[" + invoiceNo + "]分录[" + seq + "]税率[" + taxRateText.trim() + "]无法识别,请人工核实"); + } + if (rate.compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("推送金蝶收票单失败:发票[" + invoiceNo + "]分录[" + seq + "]为免税/零税率行,无法映射税率表,请人工核实"); + } + // 小于 1 的小数按比率换算百分比(0.13→13),"1" 按百分比(1%→V1) + if (rate.compareTo(BigDecimal.ONE) < 0) { + rate = rate.multiply(BigDecimal.valueOf(100)); + } + return "V" + rate.stripTrailingZeros().toPlainString(); + } + + /** 镜像明细数值解析:空返回 null,不可解析返回 null(数量/单价有兜底,金额空不送) */ + private BigDecimal parseMirrorAmountQuietly(String value) { + if (Func.isBlank(value)) { + return null; + } + try { + return new BigDecimal(value.trim()); + } catch (NumberFormatException exception) { + return null; + } + } + + /** 镜像明细金额解析:空返回 null,不可解析按脏数据拦截(金额直接进分录,不能静默丢弃) */ + private BigDecimal parseMirrorAmount(String invoiceNo, String seq, String fieldLabel, String value) { + if (Func.isBlank(value)) { + return null; + } + try { + return new BigDecimal(value.trim()); + } catch (NumberFormatException exception) { + throw new ServiceException("推送金蝶收票单失败:发票[" + invoiceNo + "]分录[" + seq + "]" + fieldLabel + "[" + value.trim() + "]无法解析,请先按票号补查票池明细"); + } + } + // ==================== A302-6 收票指定应付 ==================== /**