diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java index a8449f55..864b2f6f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java @@ -34,11 +34,14 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.SysCache; +import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.entity.Dept; +import org.springblade.system.pojo.entity.InvoiceItem; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO; @@ -89,6 +92,7 @@ import org.springblade.transport.pojo.vo.CustomerInvoiceInfoVO; import org.springblade.transport.pojo.vo.InvoiceApplicationSheetVO; import org.springblade.transport.pojo.vo.InvoiceApplicationVO; import org.springblade.transport.service.IInvoiceApplicationService; +import org.springblade.transport.service.KingdeeSyncContext; import org.springblade.transport.wrapper.InvoiceApplicationWrapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -127,6 +131,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl selectPage(IPage page, InvoiceApplicationVO query) { @@ -531,7 +538,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl (Func.toStr(info.getBankName()).trim() + " " + Func.toStr(info.getBankAccount()).trim()).trim()) + .filter(Func::isNotBlank).findFirst().orElse(""); + if (Func.isBlank(salerBank)) { + throw new ServiceException("推送金蝶开票申请失败:开票单位[" + Func.toStr(entity.getIssuerName()) + + "]未在客商开票信息中维护开户行与银行账号,请补充后重试"); + } if (Func.isBlank(entity.getDepartmentEmails())) throw new ServiceException("推送金蝶开票申请失败:部门邮箱(发票推送邮箱)不能为空"); if (Func.isBlank(entity.getTaxpayerNo())) throw new ServiceException("推送金蝶开票申请失败:购方纳税人识别号不能为空"); Dept dept = entity.getDeptId() == null ? null : SysCache.getDept(entity.getDeptId()); @@ -551,26 +571,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl items = new ArrayList<>(); - List sheets = sheetMapper.selectList(Wrappers.lambdaQuery() - .eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo)); - BigDecimal amountNoTaxTotal = BigDecimal.ZERO; - BigDecimal taxTotal = BigDecimal.ZERO; - for (InvoiceApplicationSheet sheet : sheets) { - List sheetLines = lineMapper.selectList(Wrappers.lambdaQuery() - .eq(InvoiceApplicationLine::getInvoiceSheetId, sheet.getId()).orderByAsc(InvoiceApplicationLine::getLineNo)); - for (InvoiceApplicationLine line : sheetLines) { - if (Func.isBlank(line.getTaxClassificationCode())) { - throw new ServiceException("推送金蝶开票申请失败:商品行[" + Func.toStr(line.getGoodsName()) - + "]缺少税收分类编码,请编辑开票申请重新选择开票内容"); - } - items.add(buildInvoiceApplyEntry(line, invoiceApplyConfig)); - amountNoTaxTotal = amountNoTaxTotal.add(money(line.getAmountNoTax())); - taxTotal = taxTotal.add(money(line.getTaxAmount())); - } - } - if (Func.isEmpty(items)) throw new ServiceException("推送金蝶开票申请失败:开票申请无商品行,无法组装分录"); + // 单据体分录:按开票明细→应收明细→费用明细展开(费用项名称聚合行),开票内容页面商品行明细数据不可信, + // 仅以其开票金额合计为准(分录含税金额按费用归集占比分摊,尾差进最后一行) + List items = this.resolveInvoiceApplyEntries(entity); BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO(); // ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ==================== // 【必填】组织.编码:申请部门 deptId → 部门编码 @@ -601,6 +604,10 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl 0 - ? line.getQuantity() : BigDecimal.ONE; - item.setNum(quantity); - item.setUnit(Func.isBlank(line.getUnit()) ? "个" : line.getUnit()); - // 单价(含税)=价税合计/数量;金额(含税)=价税合计 - item.setTaxunitprice(money(line.getTotalAmount()).divide(quantity, 6, RoundingMode.HALF_UP)); - item.setTaxamount(money(line.getTotalAmount())); - // 金额(不含税)/单价(不含税)/税额 - item.setAmount(money(line.getAmountNoTax())); - item.setUnitprice(money(line.getUnitPriceNoTax())); - item.setTax(money(line.getTaxAmount())); - // 税率:0-100 转小数文本(13 -> 0.13),0 税率输出 0.00 - BigDecimal taxRate = line.getTaxRate() == null ? BigDecimal.ZERO : line.getTaxRate(); - item.setTaxrate(taxRate.compareTo(BigDecimal.ZERO) == 0 ? "0.00" : taxRate.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP) - .stripTrailingZeros().toPlainString()); - item.setRemark(line.getRemark()); - // 外部系统来源分录标识:商品行id(雪花id天然唯一);【必填】外部单据类型(配置,默认 0KzXs) - item.setAwrpEntryCode(String.valueOf(line.getId())); - item.setAwrpSrcType(config.getSrcType()); - return item; + /** + * A303-1 单据体分录组装:开票明细 → 正式结算明细 → 应收货物费用行(复用财务应收单 resolveArEntryGroups, + * 同链路同粒度:每条费用行一条分录不聚合,awrp_entry_code=费用行id,与应收单分录逐行勾稽); + * 开票内容页面商品行明细不可信,仅以申请单开票金额合计为准(分录含税金额按费用行金额占比分摊,尾差进最后一行); + * 开票品目按「运费」简称实时匹配开票项目主数据(分类/税收分类编码/默认税率均来自该档案) + */ + private List resolveInvoiceApplyEntries(InvoiceApplication entity) { + List applicationDetails = applicationDetailMapper.selectList(Wrappers.lambdaQuery() + .eq(InvoiceApplicationDetail::getInvoiceApplicationId, entity.getId()) + .orderByAsc(InvoiceApplicationDetail::getLineNo)); + if (Func.isEmpty(applicationDetails)) { + throw new ServiceException("推送金蝶开票申请失败:开票申请无开票明细,无法按费用明细组装分录"); + } + List settlementDetailIds = applicationDetails.stream().map(InvoiceApplicationDetail::getFormalSettlementDetailId) + .filter(Objects::nonNull).distinct().toList(); + if (Func.isEmpty(settlementDetailIds)) { + throw new ServiceException("推送金蝶开票申请失败:开票明细缺少来源正式结算明细,请重新选择开票明细"); + } + List settlementDetails = formalSettlementDetailMapper.selectBatchIds(settlementDetailIds).stream() + .filter(detail -> !Objects.equals(detail.getIsDeleted(), 1)).toList(); + // 明细分录:每条应收货物费用行一条(不聚合,无吨位时数量兜底 1),与财务应收单(A302-2)分录同一取数 + KingdeeSyncContext context = KingdeeSyncContext.empty(); + List entryGroups = kingdeeArBillSyncSupport.resolveArEntryGroups(settlementDetails, context); + if (Func.isEmpty(entryGroups)) { + throw new ServiceException("推送金蝶开票申请失败:开票明细费用行均为空,无法组装分录"); + } + InvoiceItem freightItem = this.invoiceItemByShortName().get(INVOICE_FEE_NAME_FREIGHT); + if (freightItem == null || Func.isBlank(freightItem.getTaxClassificationCode())) { + throw new ServiceException("推送金蝶开票申请失败:开票项目页面未维护简称[" + INVOICE_FEE_NAME_FREIGHT + + "]的启用的开票项目(或税收分类编码为空),请补充后重试"); + } + BigDecimal taxRate = money(freightItem.getDefaultTaxRate()); + BigDecimal targetTotal = money(entity.getInvoiceAmount()); + BigDecimal sourceTotal = entryGroups.stream().map(KingdeeApBillSyncSupport.BfiEntryGroup::amount).reduce(BigDecimal.ZERO, BigDecimal::add); + List items = new ArrayList<>(); + BigDecimal allocated = BigDecimal.ZERO; + for (int index = 0; index < entryGroups.size(); index++) { + KingdeeApBillSyncSupport.BfiEntryGroup entryGroup = entryGroups.get(index); + // 含税金额:以页面开票金额合计为准按费用行金额占比分摊,尾差由最后一行兜底 + BigDecimal taxAmount = index == entryGroups.size() - 1 ? targetTotal.subtract(allocated) + : sourceTotal.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO + : money(entryGroup.amount()).multiply(targetTotal).divide(sourceTotal, 2, RoundingMode.HALF_UP); + allocated = allocated.add(taxAmount); + // 不含税金额 = 价税合计 ÷ (1 + 税率),税额 = 差额(税率为小数,直接参与换算) + BigDecimal amountNoTax = taxAmount.divide(BigDecimal.ONE.add(taxRate), 2, RoundingMode.HALF_UP); + BigDecimal quantity = entryGroup.quantity() != null && entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 + ? entryGroup.quantity() : BigDecimal.ONE; + BfiInvoiceApplySaveEntryDTO item = new BfiInvoiceApplySaveEntryDTO(); + // 【必填】行性质:2 商品行(固定;联调校准项) + item.setRowtype("2"); + // 【必填】基准类型:0 数量基准(固定,按数量开票) + item.setBenchmark("0"); + // 【必填】商品名称:开票项目「货物或服务简称」(运费) + item.setGoodsname(freightItem.getShortName()); + // 【必填】货物或服务简称(商品和服务分类):开票项目「商品和服务分类名称」 + item.setGoodssimplename(freightItem.getCategoryName()); + // 【必填】税收分类编码.编码:开票项目「税收分类编码」 + String taxratecodeidNumber = freightItem.getTaxClassificationCode().trim(); + // todo test + taxratecodeidNumber = "304060299"; + item.setTaxratecodeidNumber(taxratecodeidNumber); + // 税收分类合并编码(联调确认必传):开票项目「税收分类编码」,如有补位规则联调后调整 + item.setGoodscode(taxratecodeidNumber); + // 数量:应收货物费用行运输数量(与财务应收单分录同源,无吨位兜底 1;折扣行才填 0) + item.setNum(quantity); + // 计量单位:费用行运费计算单位(兜底数量单位;档案暂无编码传名称,空兜底「次」) + item.setUnit(Func.isBlank(entryGroup.measureunitNumber()) ? "次" : entryGroup.measureunitNumber()); + // 规格型号:费用行无稳定规格来源,不传 + // 费用项目.编码:金蝶侧无对应档案映射,不传 + // 单价(含税):分摊后含税金额 ÷ 数量(6 位小数) + item.setTaxunitprice(taxAmount.divide(quantity, 6, RoundingMode.HALF_UP)); + // 金额(含税):费用行调整后总金额按占比分摊到开票金额合计(Σ分录 = 申请价税合计,尾差最后一行) + item.setTaxamount(taxAmount); + // 金额(不含税):含税金额 ÷ (1 + 税率)(2 位小数) + item.setAmount(amountNoTax); + // 单价(不含税):不含税金额 ÷ 数量(6 位小数) + item.setUnitprice(amountNoTax.divide(quantity, 6, RoundingMode.HALF_UP)); + // 税额:含税 − 不含税差额 + item.setTax(taxAmount.subtract(amountNoTax)); + // 税率:开票项目「默认税率」直接透传(库中即小数值:0.13/0.06,money 后两位文本 0 -> "0.00") + item.setTaxrate(taxRate.toPlainString()); + // 优惠政策标识/内容:本期无优惠场景,不传 + // 剩余可申请数量/金额/税额:差额征税抵扣场景使用,不传 + // 商品编码/物料编码:按商品类型(producttype)判定填写,本期品目走开票项目,不传 + // 明细扣除额:差额征税场景使用,不传 + // 行备注:费用行备注无开票语义,不传 + // 【必填】外部系统来源分录标识(候选键):应收货物费用行id,与财务应收单(A302-2)分录逐行勾稽 + item.setAwrpEntryCode(entryGroup.entryCode()); + // 【必填】分录外部单据类型:固定 0KzXs(配置 thirdParty.bfi.invoiceApply.srcType) + item.setAwrpSrcType(bfiProperties.getInvoiceApply().getSrcType()); + // 上游分录号:指向财务应收单分录行,回填规则待联调确认,本期不传 + items.add(item); + } + return items; + } + + /** 开票项目主数据「货物或服务简称 -> 开票项目」映射(每次同步远程加载一次;同名编码不一致时拦截) */ + private Map invoiceItemByShortName() { + R> response = sysClient.getInvoiceItems(); + if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { + throw new ServiceException("推送金蝶开票申请失败:开票项目主数据读取失败,请稍后重试"); + } + Map itemByShortName = new LinkedHashMap<>(); + for (InvoiceItem invoiceItem : response.getData()) { + if (invoiceItem == null || Func.isBlank(invoiceItem.getShortName())) { + continue; + } + String shortName = invoiceItem.getShortName().trim(); + InvoiceItem existing = itemByShortName.putIfAbsent(shortName, invoiceItem); + if (existing != null && !Func.toStr(existing.getTaxClassificationCode()).trim() + .equals(Func.toStr(invoiceItem.getTaxClassificationCode()).trim())) { + throw new ServiceException("推送金蝶开票申请失败:开票项目[" + shortName + + "]在开票项目页面维护了多个税收分类编码,请检查档案数据"); + } + } + return itemByShortName; } @Override