diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java index 207879ca..bd0130f7 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java @@ -71,6 +71,7 @@ public class InvoiceApplicationSaveRequest implements Serializable { @Serial private static final long serialVersionUID = 1L; private String goodsCategory; private String goodsName; + private String taxClassificationCode; private String unit; private BigDecimal quantity; private BigDecimal unitPriceNoTax; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java new file mode 100644 index 00000000..fa875a4f --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java @@ -0,0 +1,67 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments from this software for such purposes. + * Copyright of this software remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; + +import java.io.Serial; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 开票申请金蝶发票结果实体类 + *

+ * 定时任务经 A303-5 开票申请发票查询回写的发票明细(发票号唯一键幂等落库), + * 原文 JSON 整体留档便于联调与追溯 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_invoice_application_invoice") +@Schema(description = "开票申请金蝶发票结果") +public class InvoiceApplicationInvoice extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "开票申请ID") + private Long invoiceApplicationId; + + @Schema(description = "金蝶开票申请单编号(A303-5 serialNo)") + private String serialNo; + + @Schema(description = "发票号码") + private String invoiceNo; + + @Schema(description = "开票日期") + private LocalDateTime invoiceDate; + + @Schema(description = "价税合计") + private BigDecimal invoiceAmount; + + @Schema(description = "A303-5 返回原文JSON留档") + private String rawJson; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java index 30ded9f6..1a63d8d9 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java @@ -48,6 +48,7 @@ public class InvoiceApplicationLine extends TenantEntity { private Integer lineNo; private String goodsCategory; private String goodsName; + private String taxClassificationCode; private String unit; private BigDecimal quantity; private BigDecimal unitPriceNoTax; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java index 664d02b0..2d497480 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java @@ -30,6 +30,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.transport.pojo.entity.InvoiceApplication; import org.springblade.transport.pojo.entity.InvoiceApplicationDetail; +import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; import org.springblade.transport.pojo.entity.InvoiceApplicationRecord; import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement; @@ -53,4 +54,5 @@ public class InvoiceApplicationVO extends InvoiceApplication { @TableField(exist = false) private List sheets; @TableField(exist = false) private List details; @TableField(exist = false) private List records; + @TableField(exist = false) private List invoices; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java index c3ac9cc3..f7287ead 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java @@ -156,4 +156,11 @@ public class InvoiceApplicationController extends BladeController { public R syncKingdee(@RequestParam Long id) { return R.data(invoiceApplicationService.syncKingdee(id)); } + + @PostMapping("/sync-kingdee-invoice-result") + @ApiOperationSupport(order = 13) + @Operation(summary = "手动回写金蝶开票结果(A303-5)") + public R syncKingdeeInvoiceResult() { + return R.data(invoiceApplicationService.syncKingdeeInvoiceResult()); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationInvoiceMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationInvoiceMapper.java new file mode 100644 index 00000000..b418deef --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationInvoiceMapper.java @@ -0,0 +1,32 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments from this software for such purposes. + * Copyright of this software remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; + +/** + * 开票申请金蝶发票结果 Mapper + * + * @author Chill + */ +public interface InvoiceApplicationInvoiceMapper extends BaseMapper { + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java index 93fe9fa5..b126e786 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java @@ -55,4 +55,5 @@ public interface IInvoiceApplicationService extends BaseService implements IInvoiceApplicationService { @@ -105,6 +125,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl selectPage(IPage page, InvoiceApplicationVO query) { @@ -151,6 +178,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(InvoiceApplicationRecord::getInvoiceApplicationId, id) .orderByAsc(InvoiceApplicationRecord::getCreateTime))); + vo.setInvoices(invoiceApplicationInvoiceMapper.selectList(Wrappers.lambdaQuery() + .eq(InvoiceApplicationInvoice::getInvoiceApplicationId, id) + .orderByAsc(InvoiceApplicationInvoice::getCreateTime))); return vo; } @@ -441,16 +471,368 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)); + if (Func.isEmpty(relations)) throw new ServiceException("推送金蝶开票申请失败:开票申请未关联正式结算单"); + List arSrcCodes = new ArrayList<>(); + String firstArBillNo = null; + Map settlementNoBySrcCode = new LinkedHashMap<>(); + for (InvoiceApplicationSettlement relation : relations) { + FormalSettlement settlement = formalSettlementMapper.selectById(relation.getFormalSettlementId()); + if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) { + throw new ServiceException("推送金蝶开票申请失败:正式结算单[" + Func.toStr(relation.getFormalSettlementNo()) + "]不存在"); + } + List details = formalSettlementDetailMapper.selectList( + Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId()) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + String srcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details); + arSrcCodes.add(srcCode); + settlementNoBySrcCode.put(srcCode, Func.toStr(settlement.getFormalSettlementNo())); + if (firstArBillNo == null) { + firstArBillNo = settlement.getKingdeeBillNo(); + } + } + Map existArNos = kingdeeArBillSyncSupport.queryArBillExistNos(arSrcCodes); + for (Map.Entry entry : settlementNoBySrcCode.entrySet()) { + if (Func.isBlank(existArNos.get(entry.getKey()))) { + throw new ServiceException("推送金蝶开票申请失败:正式结算单[" + entry.getValue() + + "]财务应收单未在金蝶生成,请先在正式付款单页面同步金蝶"); + } + } + // A303-4 幂等预检:按申请单号(billno 候选键)查询金蝶是否已生成开票申请单(防上次推送成功但本地回滚的断链重推) + BfiInvoiceApplyQueryDTO idempotentQuery = new BfiInvoiceApplyQueryDTO(); + idempotentQuery.setPageNo(1); + idempotentQuery.setPageSize(10); + idempotentQuery.setBillno(List.of(entity.getApplicationNo())); + List existApplies = List.of(); + BfiPageVO existPage = bfiService.queryInvoiceApply(idempotentQuery); + if (existPage != null && Func.isNotEmpty(existPage.getRows())) { + existApplies = existPage.getRows(); + } + for (BfiInvoiceApplyVO existApply : existApplies) { + if (existApply != null && entity.getApplicationNo().equals(existApply.getBillno())) { + // 金蝶已生成:直接回填视为同步成功(勾稽纠偏) + entity.setKingdeeBillNo(existApply.getBillno()); + entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); + entity.setSyncedTime(LocalDateTime.now()); + updateById(entity); + record(entity.getId(), "sync", "同步金蝶(勾稽回填)", APPROVED, APPROVED, null, existApply.getBillno()); + return existApply.getBillno(); + } + } + // 组装 A303-1 开票申请单(字段映射见设计文档 6.3/6.4) + BfiProperties.InvoiceApply invoiceApplyConfig = bfiProperties.getInvoiceApply(); + if (Func.isBlank(entity.getIssuerName())) { + throw new ServiceException("推送金蝶开票申请失败:开票单位(销方)为空"); + } + // 销方税号:按开票单位走客商档案取统一社会信用代码(与 A303-5 查询同源) + CustomerArchive seller = findCustomer(entity.getIssuerName()); + if (seller == null || Func.isBlank(seller.getUnifiedCreditCode())) { + 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()); + if (dept == null || Func.isBlank(dept.getDeptCode())) { + throw new ServiceException("推送金蝶开票申请失败:申请部门[" + Func.toStr(entity.getDeptId()) + "]未维护部门编码,请检查部门数据"); + } + CustomerArchive customer = entity.getReceiverCustomerId() == null ? null : customerArchiveMapper.selectById(entity.getReceiverCustomerId()); + if (customer == null || Func.isBlank(customer.getCustomerCode())) { + throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试"); + } + // 商品行分录:发票张内商品行平铺(行性质固定商品行) + List 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("推送金蝶开票申请失败:开票申请无商品行,无法组装分录"); + BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO(); + // ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ==================== + // 【必填】组织.编码:申请部门 deptId → 部门编码 + param.setOrgidNumber(dept.getDeptCode()); + // 【必填】申请部门.编码:同上 + param.setAwrpOrgfieldNumber(dept.getDeptCode()); + // 【必填】业务单据编号(候选键):开票申请单号 + param.setBillno(entity.getApplicationNo()); + // 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss + LocalDate billDate = entity.getApplicationDate() == null ? LocalDate.now() : entity.getApplicationDate(); + param.setBilldate(billDate.atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + // 【必填】单据性质:1 正数 + param.setBillproperties("1"); + // 【必填】发票种类:数电普票 10xdp / 数电专票 08xdp + param.setInvoicetype("electronic_special".equals(entity.getInvoiceType()) + ? KingdeeBfiConstant.BFI_INVOICE_TYPE_DIGITAL_SPECIAL : KingdeeBfiConstant.BFI_INVOICE_TYPE_DIGITAL_GENERAL); + // 【必填】征税方式:0 普通征税 + param.setTaxationstyle("0"); + // 【必填】发票推送邮箱:部门邮箱 + param.setBuyeremail(entity.getDepartmentEmails()); + // 【必填】购方企业类型:0 企业 + param.setBuyerproperty("0"); + // 【必填】购方纳税人识别号:受票方开票信息快照 + param.setBuyertaxno(entity.getTaxpayerNo()); + // 【必填】购方名称:受票方单位 + param.setBuyername(entity.getReceiverName()); + // 【必填】销方名称:开票单位快照(结算单收款方带出) + param.setSalername(entity.getIssuerName()); + // 【必填】销方纳税人识别号:开票单位 → 客商档案统一社会信用代码(A303-5 查询同源) + param.setSalertaxno(seller.getUnifiedCreditCode().trim()); + // 【必填】申请价税合计:开票金额 + param.setTotalamount(money(entity.getInvoiceAmount())); + // 【必填】客户.编码:受票方客商档案客商编码 + param.setCustomnameidNumber(customer.getCustomerCode()); + // 【必填】项目.项目编码(联调期临时传金蝶测试档案值 999) + param.setAwrpProjectNumber(KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE); + // ==================== 以下为非必填字段,先注释保留,联调确认后按需放开 ==================== + // 申请金额(不含税):商品行不含税合计 + // param.setInvoiceamount(amountNoTaxTotal); + // 申请税额:商品行税额合计 + // param.setTotaltax(taxTotal); + // 数据来源:固定 3 + // param.setBillsource("3"); + // 来源单据:ar_finarbill 财务应收单 + // param.setAwrpSrcBt(KingdeeBfiConstant.BFI_AR_BILL_SRC_BT); + // 上游外部系统单据号:第一张关联结算单的金蝶应收单号(多张拼接规则待联调确认) + // param.setAwrpSrcConNo(firstArBillNo); + // 数据来源系统:暂不传,待金蝶侧确认(联调校准项) + + // 单据体分录:发票张内商品行平铺(缺失金蝶无法开票,联调主表通过后放开) + // param.setSimOriginalBillItem(items); + + BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param); + kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶开票申请"); + String kingdeeNo = saveResult.getResult().get(0).getNumber(); entity.setKingdeeBillNo(kingdeeNo); - entity.setKingdeeStatus("synced"); + entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); entity.setSyncedTime(LocalDateTime.now()); updateById(entity); - record(entity.getId(), "sync", "同步金蝶", entity.getApprovalStatus(), entity.getApprovalStatus(), null, kingdeeNo); + record(entity.getId(), "sync", "同步金蝶", APPROVED, APPROVED, null, kingdeeNo); return kingdeeNo; } + /** A303-1 商品行分录组装:税率为 0-100 转小数文本,含税单价=价税合计/数量(数量为空时兜底 1) */ + private BfiInvoiceApplySaveEntryDTO buildInvoiceApplyEntry(InvoiceApplicationLine line, BfiProperties.InvoiceApply config) { + BfiInvoiceApplySaveEntryDTO item = new BfiInvoiceApplySaveEntryDTO(); + // 【必填】行性质:商品行;基准类型:数量基准 + item.setRowtype("2"); + item.setBenchmark("0"); + // 【必填】商品名称 / 简称(商品和服务分类) + item.setGoodsname(line.getGoodsName()); + item.setGoodssimplename(line.getGoodsCategory()); + // 【必填】税收分类编码.编码:商品行开票内容快照(开票项目主数据) todo + // item.setTaxratecodeidNumber(line.getTaxClassificationCode()); + item.setTaxratecodeidNumber("304060299"); + // 数量/计量单位(行无单位字段时兜底) + BigDecimal quantity = line.getQuantity() != null && line.getQuantity().compareTo(BigDecimal.ZERO) > 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; + } + + @Override + public String syncKingdeeInvoiceResult() { + // 待回写开票申请:审批通过 + 已同步(invoiced 为终态不再轮询;failed 无真实金蝶单号不参与查询,纠偏走重推预检) + List applications = list(Wrappers.lambdaQuery() + .eq(InvoiceApplication::getApprovalStatus, APPROVED) + .eq(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED) + .isNotNull(InvoiceApplication::getKingdeeBillNo) + .ne(InvoiceApplication::getKingdeeBillNo, "") + .orderByAsc(InvoiceApplication::getId)); + if (Func.isEmpty(applications)) { + return "金蝶开票结果回写:无待回写开票申请"; + } + // 按 serialNo(金蝶开票申请单编号)分批(≤50/批)调 A303-5,销方税号与 A303-1 同源(开票单位→客商档案),按税号分组查询 + Map applicationBySerialNo = new LinkedHashMap<>(); + applications.forEach(application -> { + if (Func.isNotBlank(application.getKingdeeBillNo())) { + applicationBySerialNo.putIfAbsent(application.getKingdeeBillNo(), application); + } + }); + Map sellerCache = new LinkedHashMap<>(); + Map> serialNosBySellerTaxNo = new LinkedHashMap<>(); + int unresolved = 0; + for (Map.Entry entry : applicationBySerialNo.entrySet()) { + InvoiceApplication application = entry.getValue(); + String issuerName = Func.toStr(application.getIssuerName()).trim(); + if (Func.isBlank(issuerName)) { + unresolved++; + log.warn("金蝶开票结果回写:开票申请[{}]开票单位为空,跳过", application.getApplicationNo()); + continue; + } + CustomerArchive seller = sellerCache.computeIfAbsent(issuerName, this::findCustomer); + String sellerTaxNo = seller == null ? null : Func.toStr(seller.getUnifiedCreditCode()).trim(); + if (Func.isBlank(sellerTaxNo)) { + unresolved++; + log.warn("金蝶开票结果回写:开票申请[{}]开票单位[{}]未在客商档案维护统一社会信用代码,跳过", + application.getApplicationNo(), issuerName); + continue; + } + serialNosBySellerTaxNo.computeIfAbsent(sellerTaxNo, key -> new ArrayList<>()).add(entry.getKey()); + } + int invoiceWritten = 0; + int failed = 0; + int invoicedCount = 0; + for (Map.Entry> group : serialNosBySellerTaxNo.entrySet()) { + List groupSerialNos = group.getValue(); + for (int from = 0; from < groupSerialNos.size(); from += INVOICE_RESULT_QUERY_BATCH) { + List batch = groupSerialNos.subList(from, Math.min(from + INVOICE_RESULT_QUERY_BATCH, groupSerialNos.size())); + try { + BfiInvoiceApplyOpenApiQueryDTO param = new BfiInvoiceApplyOpenApiQueryDTO(); + param.setSerialNo(String.join(",", batch)); + param.setSellerTaxpayerId(group.getKey()); + BfiOpenApiResultVO> result = bfiService.queryInvoiceApplyInvoice(param); + if (result == null || !result.isSuccess() || result.getData() == null) { + failed++; + log.warn("金蝶开票结果回写批次查询未成功:serialNo=[{}]", String.join(",", batch)); + continue; + } + for (Object billItem : result.getData()) { + if (!(billItem instanceof Map)) { + continue; + } + Map billMap = (Map) billItem; + String billNo = firstText(billMap, "billNo", "billno"); + InvoiceApplication application = applicationBySerialNo.get(billNo); + if (application == null || !(billMap.get("invoiceList") instanceof List)) { + continue; + } + int inserted = 0; + for (Object invoiceItem : (List) billMap.get("invoiceList")) { + if (!(invoiceItem instanceof Map)) { + continue; + } + Map invoice = (Map) invoiceItem; + // 宽松提取关键字段(票易通原文,字段名联调校准后收敛),原文整体 JSON 留档 + String invoiceNo = firstText(invoice, "invoiceNo", "invoice_no", "invoiceNumber"); + if (Func.isBlank(invoiceNo)) { + continue; + } + Long exists = invoiceApplicationInvoiceMapper.selectCount( + Wrappers.lambdaQuery() + .eq(InvoiceApplicationInvoice::getInvoiceApplicationId, application.getId()) + .eq(InvoiceApplicationInvoice::getInvoiceNo, invoiceNo)); + if (exists != null && exists > 0) { + continue; + } + InvoiceApplicationInvoice record = new InvoiceApplicationInvoice(); + record.setInvoiceApplicationId(application.getId()); + record.setSerialNo(billNo); + record.setInvoiceNo(invoiceNo); + record.setInvoiceDate(parseInvoiceDate(firstText(invoice, "invoiceDate", "invoice_date", "issueDate", "billingDate"))); + record.setInvoiceAmount(parseDecimal(firstText(invoice, "amountWithTax", "totalAmount", "invoiceAmount", "priceTaxTotal"))); + record.setRawJson(JsonUtil.toJson(invoice)); + invoiceApplicationInvoiceMapper.insert(record); + inserted++; + } + if (inserted > 0) { + invoiceWritten += inserted; + // 查到发票:开票申请单推进开票完成(终态) + application.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_INVOICED); + updateById(application); + invoicedCount++; + } + } + } catch (Exception exception) { + failed++; + log.error("金蝶开票结果回写批次异常:serialNo=[{}]", String.join(",", batch), exception); + } + } + } + return "金蝶开票结果回写完成:待查开票申请" + applications.size() + "笔(销方税号无法解析跳过" + unresolved + + "笔),回写发票" + invoiceWritten + "张(开票完成" + invoicedCount + "笔,失败批次" + failed + "个)"; + } + + /** 从票易通原文 Map 中按候选键顺序取首个非空文本值 */ + private String firstText(Map map, String... keys) { + for (String key : keys) { + Object value = map.get(key); + if (value != null && Func.isNotBlank(String.valueOf(value))) { + return String.valueOf(value).trim(); + } + } + return null; + } + + /** 宽松解析开票日期文本(支持 yyyy-MM-dd HH:mm:ss / yyyy-MM-dd / ISO),无法解析返回 null */ + private LocalDateTime parseInvoiceDate(String text) { + if (Func.isBlank(text)) { + return null; + } + String value = text.trim(); + List formatters = List.of( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), + DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy/MM/dd"), + DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ISO_LOCAL_DATE); + for (DateTimeFormatter formatter : formatters) { + try { + if (formatter.equals(DateTimeFormatter.ISO_LOCAL_DATE_TIME) || formatter.toString().contains("HH")) { + return LocalDateTime.parse(value, formatter); + } + return LocalDate.parse(value, formatter).atStartOfDay(); + } catch (Exception ignored) { + // 尝试下一种格式 + } + } + log.warn("金蝶开票结果开票日期解析失败:[{}]", text); + return null; + } + + /** 宽松解析金额文本,无法解析返回 null */ + private BigDecimal parseDecimal(String text) { + if (Func.isBlank(text)) { + return null; + } + try { + return new BigDecimal(text.trim()).setScale(2, RoundingMode.HALF_UP); + } catch (Exception exception) { + log.warn("金蝶开票结果金额解析失败:[{}]", text); + return null; + } + } + private InvoiceApplicationVO toListVO(InvoiceApplication entity) { InvoiceApplicationVO vo = InvoiceApplicationWrapper.build().entityVO(entity); vo.setSettlementNos(settlementRelationMapper.selectList(Wrappers.lambdaQuery() diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java index 5528198b..0a7fc051 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java @@ -58,6 +58,7 @@ public class InvoiceApplicationWrapper extends BaseEntityWrapper "未同步"; case "synced" -> "已同步"; + case "invoiced" -> "开票完成"; case "failed" -> "同步失败"; default -> entity.getKingdeeStatus(); }); diff --git a/doc/sql/transport/blade_invoice_application_invoice_20260920.sql b/doc/sql/transport/blade_invoice_application_invoice_20260920.sql new file mode 100644 index 00000000..12e77271 --- /dev/null +++ b/doc/sql/transport/blade_invoice_application_invoice_20260920.sql @@ -0,0 +1,22 @@ +-- 开票申请金蝶发票结果表(A303-5 定时回写落库,发票号唯一幂等) + +CREATE TABLE IF NOT EXISTS `blade_invoice_application_invoice` ( + `id` bigint(20) NOT NULL COMMENT '主键', + `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID', + `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int(11) DEFAULT '1' COMMENT '状态', + `is_deleted` int(11) DEFAULT '0' COMMENT '是否删除', + `invoice_application_id` bigint(20) NOT NULL COMMENT '开票申请ID', + `serial_no` varchar(64) DEFAULT NULL COMMENT '金蝶开票申请单编号(A303-5 serialNo)', + `invoice_no` varchar(64) NOT NULL COMMENT '发票号码', + `invoice_date` datetime DEFAULT NULL COMMENT '开票日期', + `invoice_amount` decimal(18,2) DEFAULT NULL COMMENT '价税合计', + `raw_json` text COMMENT 'A303-5返回原文JSON留档', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_application_invoice` (`tenant_id`,`invoice_application_id`,`invoice_no`), + KEY `idx_iai_application_id` (`invoice_application_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='开票申请金蝶发票结果'; diff --git a/doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql b/doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql new file mode 100644 index 00000000..dd06c8f5 --- /dev/null +++ b/doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql @@ -0,0 +1,21 @@ +-- 应收结算与开票申请同步金蝶(A303-1 开票申请推送) +-- 开票申请商品行新增字段: +-- tax_classification_code 税收分类编码(开票项目主数据 blade_invoice_item.tax_classification_code, +-- 新增/编辑开票申请选择开票内容时快照落库,A303-1 分录 taxratecodeid.number 取数来源) +-- 销方名称/销方税号不走 Nacos 配置与表结构:销方名称取开票申请开票单位快照(issuer_name,结算单收款方带出), +-- 销方税号按开票单位查客商档案统一社会信用代码(unified_credit_code) +-- 开票申请基础建表脚本 blade_invoice_application_20260821.sql 保持不动,本脚本支持重复执行 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_line` ADD COLUMN `tax_classification_code` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''税收分类编码(开票内容快照)'' AFTER `goods_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_line' + AND COLUMN_NAME = 'tax_classification_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt;