From 02e6f06f370301dd9c8ba37af81b6390aa0b6acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sat, 31 Oct 2026 16:06:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E7=A5=A8=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E6=94=B9=E5=85=88=E7=A5=A8=E5=90=8E=E6=AC=BE?= =?UTF-8?q?=E7=BB=88=E6=80=81pushed=E5=B9=B6=E6=94=AF=E6=8C=81=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E6=9F=A5=E8=AF=A2/=E8=AE=A4=E9=A2=86=E5=80=99?= =?UTF-8?q?=E9=80=89/=E5=85=B3=E7=B3=BB=E5=90=8C=E6=AD=A5/=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E5=BA=94=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InvoiceReceiptController.java | 18 +- .../service/IInvoiceReceiptService.java | 51 +- .../impl/InvoiceReceiptServiceImpl.java | 470 ++++++++++++------ 3 files changed, 375 insertions(+), 164 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java index c9023ceb..4e6ac1f5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java @@ -36,10 +36,12 @@ import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolItemVO; import org.springblade.transport.listener.KingdeeApprovalSyncListener; import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; +import org.springblade.transport.pojo.entity.InvoiceReceipt; import org.springblade.transport.pojo.vo.InvoiceReceiptVO; import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; import org.springblade.transport.service.IInvoiceReceiptService; @@ -181,7 +183,7 @@ public class InvoiceReceiptController extends BladeController { @PostMapping("/push-kingdee") @ApiOperationSupport(order = 15) - @Operation(summary = "批量推送金蝶收票单(A303-2 收票单保存 + A302-6 收票指定应付)") + @Operation(summary = "批量推送金蝶收票单(A303-2 收票单保存,先票后款:指定应付由付款结算单侧执行)") public R pushKingdee(@RequestBody List ids) { return R.data(invoiceReceiptService.pushKingdeeReceivedInvoice(ids)); } @@ -192,4 +194,18 @@ public class InvoiceReceiptController extends BladeController { public R syncKingdeeResult() { return R.data(invoiceReceiptService.syncKingdeeReceivedInvoiceResult()); } + + @GetMapping("/invoice-detail-query") + @ApiOperationSupport(order = 17) + @Operation(summary = "发票明细查询(先票后款:收票登记明细区块按票号带出发票明细行)") + public R> invoiceDetailQuery(@RequestParam String invoiceNo) { + return R.data(invoiceReceiptService.invoiceDetailQuery(invoiceNo)); + } + + @GetMapping("/claim-candidates") + @ApiOperationSupport(order = 18) + @Operation(summary = "结算单认领收票单候选(先票后款:审批通过且未被有效应付结算单认领)") + public R> claimCandidates(@RequestParam(required = false) String keyword) { + return R.data(invoiceReceiptService.claimCandidates(keyword)); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java index c8756876..fb3acc45 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java @@ -28,6 +28,7 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.metadata.IPage; import jakarta.servlet.http.HttpServletResponse; import org.springblade.core.mp.base.BaseService; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolItemVO; import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; @@ -105,10 +106,10 @@ public interface IInvoiceReceiptService extends BaseService { void voidBill(InvoiceReceiptStatusRequest request); /** - * 批量推送金蝶收票单(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付)。 - *

逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、恰好分摊一张应付结算单)→ - * A102-4 预检财务应付单已生成 → A801-3 幂等预检(命中跳过保存续推指定)→ A303-2 保存 → - * 立即 A302-6 指定应付;成功置 assigned 并锁单,失败置 failed 提示人工处理后重推。 + * 批量推送金蝶收票单(A303-2 收票单批量保存,先票后款:推送即终态 pushed)。 + *

逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、已选结算组织)→ + * A801-3 幂等预检(命中跳过保存)→ A303-2 保存(分录=发票明细行)→ 终态 pushed; + * A302-6 指定应付由付款结算单侧在财务应付单(A102-2)生成后按认领关系执行 * * @param ids 收票登记id列表 * @return 执行摘要(逐单失败原因) @@ -117,7 +118,7 @@ public interface IInvoiceReceiptService extends BaseService { /** * 自动推送路径批量推送金蝶收票单(审批通过事件 / 定时重推专用): - * 前置拦截(未绑定镜像/查验未通过/前置应付单未生成等)一并置 failed + 原因,纳入定时重推循环 + * 前置拦截(未绑定镜像/查验未通过/未选结算组织等)一并置 failed + 原因,纳入定时重推循环 * * @param ids 收票登记id列表 * @param autoSync 自动推送标记(前置拦截置 failed) @@ -125,6 +126,44 @@ public interface IInvoiceReceiptService extends BaseService { */ String pushKingdeeReceivedInvoice(List ids, boolean autoSync); + /** + * 发票明细查询(先票后款:收票登记明细区块按票号带出):读票池镜像 items_json, + * 镜像无明细按票号回查 A801-1 补写后重读;票未登记时直接取窄查返回行明细 + * + * @param invoiceNo 发票号码 + * @return 发票明细行 + */ + List invoiceDetailQuery(String invoiceNo); + + /** + * 结算单认领收票单候选(先票后款路径A/B 选票数据源):审批通过 + 未被有效应付结算单认领, + * 关键字匹配发票号/销方/购方,最多 200 条 + * + * @param keyword 关键字(发票号/销方/购方),空不过滤 + * @return 候选收票登记 + */ + List claimCandidates(String keyword); + + /** + * 结算单收票认领关系同步(先票后款:结算单保存/认领后由结算单侧调用): + * 按行发票号匹配审批通过收票登记,一票一结算单校验(已挂其他有效应付结算单拦截), + * 删旧建新重建本结算单认领关系(认领金额=发票金额全额)并刷新结算单收票状态 + * + * @param settlementId 正式结算单id + * @param invoiceNos 认领发票号码集合(结算单发票信息行发票号) + */ + void syncSettlementReceiptRelations(Long settlementId, List invoiceNos); + + /** + * 结算单认领收票登记逐单 A302-6 指定应付(先票后款路径:A102-2 成功后调用): + * 收票单 pushed → 指定 → assigned;未推送/已指定跳过;单笔失败收集汇总返回,不阻断结算同步 + * + * @param settlementId 正式结算单id + * @param apBillSrcCode 财务应付单防重键(A102-2 的 awrp_src_code) + * @return 失败明细(空串表示全部成功或无需指定) + */ + String assignClaimedReceipts(Long settlementId, String apBillSrcCode); + /** * 重推金蝶推送失败(kingdeePushStatus=failed 且审批通过)的收票登记:逐单独立成败,失败不中断。 * 只扫 failed 不扫 unsynced——存量"审批通过但从未推送"的历史单不自动补推,仍走手动按钮 @@ -135,7 +174,7 @@ public interface IInvoiceReceiptService extends BaseService { /** * 定时回写金蝶收票单结果(A801-3):金蝶侧删除/红冲/作废 → 回收为 failed 提示人工(不自动作废登记); - * pushed(保存成功后中断)→ 指定补偿;assigned 仅补登缺失的收票单号 + * pushed 且已认领 → 指定补偿(重试引擎),未认领跳过;assigned 仅补登缺失的收票单号 * * @return 执行摘要 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index de5b6cbe..ff3b5b79 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -70,7 +70,6 @@ import org.springblade.transport.mapper.KingdeeInvoicePoolMapper; import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; -import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.entity.CustomerContact; import org.springblade.transport.pojo.entity.CustomerInvoiceInfo; @@ -107,6 +106,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -396,12 +396,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl requestedRows = distinctSettlementRows( request.getSettlements()); Map settlementMap = lockSettlements(requestedRows.keySet().stream().sorted().toList()); List settlements = requestedRows.keySet().stream().map(settlementMap::get).toList(); - assertCompatible(settlements); - + // 历史兼容链路(传 settlements)保留原整体一致性校验;先票后款链路(不传)跳过 + if (!requestedRows.isEmpty()) { + assertCompatible(settlements); + } for (Map.Entry entry : requestedRows.entrySet()) { FormalSettlement settlement = settlementMap.get(entry.getKey()); BigDecimal allocated = nonNegative(entry.getValue().getAllocatedInvoiceAmount(), "分摊发票金额"); @@ -418,13 +421,32 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpllambdaQuery() - .eq(InvoiceReceiptSettlement::getInvoiceReceiptId, entity.getId())); - saveRelations(entity.getId(), requestedRows, settlementMap); - Set refreshIds = new LinkedHashSet<>(oldSettlementIds); - refreshIds.addAll(settlementMap.keySet()); - refreshIds.forEach(this::refreshSettlementInvoiceStatus); + if (requestedRows.isEmpty()) { + // 未传 settlements:不动既有分摊关系(先票后款链路,关系只由结算单侧重建维护) + log.info("收票登记[{}]保存未关联结算单(先票后款链路),既有分摊关系保持不动", entity.getInvoiceNo()); + } else { + settlementRelationMapper.delete(Wrappers.lambdaQuery() + .eq(InvoiceReceiptSettlement::getInvoiceReceiptId, entity.getId())); + saveRelations(entity.getId(), requestedRows, settlementMap); + Set refreshIds = new LinkedHashSet<>(oldSettlementIds); + refreshIds.addAll(settlementMap.keySet()); + refreshIds.forEach(this::refreshSettlementInvoiceStatus); + } record(entity.getId(), creating ? "create" : "save", creating ? "创建草稿" : "保存草稿", entity.getApprovalStatus(), entity.getApprovalStatus(), null, entity.getKingdeeReceivedInvoiceNo()); return entity.getId(); @@ -536,11 +563,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl - * 编排流程:参数去重校验 → 批量组装推送计划(分摊/镜像/结算单/结算明细一次取回防 N+1)→ - * A102-4 应付单已生成预检 → A801-3 幂等预检(金蝶已有收票单则跳过保存续推指定)→ - * 逐单推送(单笔失败不中断整批)→ 汇总成功/失败笔数返回 + * 编排流程:参数去重校验 → 批量组装推送计划(镜像一次取回防 N+1)→ + * A801-3 幂等预检(金蝶已有收票单则跳过保存)→ 逐单推送(单笔失败不中断整批)→ 汇总成功/失败笔数返回 * * @param ids 收票登记主键集合 * @param autoSync 自动推送路径(审批通过事件/定时重推):前置拦截置 failed + 原因纳入重推循环; @@ -561,56 +587,23 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl> relationsByReceipt = settlementRelationMapper.selectList( - Wrappers.lambdaQuery() - .in(InvoiceReceiptSettlement::getInvoiceReceiptId, distinctIds) - .orderByAsc(InvoiceReceiptSettlement::getCreateTime)).stream() - .collect(Collectors.groupingBy(InvoiceReceiptSettlement::getInvoiceReceiptId)); - // 2) 票池镜像(A801-1 同步落库的发票数据),提供明细行 itemsJson、票种编码、购方单位等推送必填字段 + // 批量组装推送计划:票池镜像一次取回防 N+1(先票后款:不再依赖分摊结算单/结算明细与 A102-4 应付单预检) List mirrorIds = receipts.stream().map(InvoiceReceipt::getKingdeeInvoicePoolId) .filter(Objects::nonNull).distinct().toList(); Map mirrorById = mirrorIds.isEmpty() ? Map.of() : invoicePoolMapper.selectBatchIds(mirrorIds).stream() .collect(Collectors.toMap(KingdeeInvoicePool::getId, Function.identity(), (first, second) -> first)); - // 3) 分摊关系指向的正式结算单,后续校验须为审批通过的应付类型结算单 - List settlementIds = relationsByReceipt.values().stream().flatMap(List::stream) - .map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); - Map settlementById = settlementIds.isEmpty() ? Map.of() - : formalSettlementMapper.selectBatchIds(settlementIds).stream() - .collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, second) -> first)); - // 4) 应付结算单的明细行(按行号升序),用于组装 A302-6 指定应付的防重键 apBillSrcCode - List payableSettlementIds = settlementById.values().stream() - .filter(settlement -> "payable".equals(settlement.getSettlementType())) - .map(FormalSettlement::getId).toList(); - Map> detailsBySettlement = payableSettlementIds.isEmpty() ? Map.of() - : formalSettlementDetailMapper.selectList(Wrappers.lambdaQuery() - .in(FormalSettlementDetail::getFormalSettlementId, payableSettlementIds) - .eq(FormalSettlementDetail::getIsDeleted, 0) - .orderByAsc(FormalSettlementDetail::getLineNo)).stream() - .collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId)); - log.info("推送金蝶收票单批量取数完成:分摊关系 {} 条,票池镜像 {} 个,结算单 {} 张(应付 {} 张),结算明细 {} 行", - relationsByReceipt.size(), mirrorById.size(), settlementById.size(), payableSettlementIds.size(), - detailsBySettlement.values().stream().mapToInt(List::size).sum()); - // 5) 逐单组装推送计划:状态机/绑定/查验/一票一应付/防重键校验,不满足项写入 blockedReason 不抛异常(逐单报因) + log.info("推送金蝶收票单批量取数完成:票池镜像 {} 个", mirrorById.size()); + // 逐单组装推送计划:状态机/绑定/查验/结算组织校验,不满足项写入 blockedReason 不抛异常(逐单报因) Map planByReceipt = new LinkedHashMap<>(); for (InvoiceReceipt receipt : receipts) { - planByReceipt.put(receipt.getId(), buildReceivedInvoicePushPlan(receipt, - relationsByReceipt.getOrDefault(receipt.getId(), List.of()), mirrorById, settlementById, detailsBySettlement)); + planByReceipt.put(receipt.getId(), buildReceivedInvoicePushPlan(receipt, mirrorById)); } long blockedCount = planByReceipt.values().stream().filter(plan -> plan.blockedReason() != null).count(); log.info("推送金蝶收票单计划组装完成:本批 {} 笔,前置校验拦截 {} 笔", planByReceipt.size(), blockedCount); - // A102-4 应付单已生成预检:本批防重键一次批量查询,缺单的登记逐单提示先完成付款侧同步 - List apSrcCodes = planByReceipt.values().stream() - .filter(plan -> plan.blockedReason() == null) - .map(ReceivedInvoicePushPlan::apBillSrcCode).filter(Func::isNotBlank).distinct().toList(); - Map apBillNoBySrcCode = apSrcCodes.isEmpty() ? Map.of() - : kingdeeApBillSyncSupport.queryApBillExistNos(apSrcCodes); - log.info("推送金蝶收票单 A102-4 预检:待校验防重键 {} 个,已生成财务应付单 {} 个", apSrcCodes.size(), apBillNoBySrcCode.size()); - // A801-3 幂等预检:按购方单位(接口必填)分组回看窗口翻页,本地按防重键匹配(金蝶已存在则跳过保存续推指定) + // A801-3 幂等预检:按购方单位(接口必填)分组回看窗口翻页,本地按防重键匹配(金蝶已存在则跳过保存) Map kingdeeRowByInvoiceNo = queryKingdeeReceivedInvoiceRows(planByReceipt); - log.info("推送金蝶收票单 A801-3 预检:金蝶已存在收票单 {} 笔,命中单据将跳过保存续推指定", kingdeeRowByInvoiceNo.size()); + log.info("推送金蝶收票单 A801-3 预检:金蝶已存在收票单 {} 笔,命中单据将跳过保存", kingdeeRowByInvoiceNo.size()); KingdeeSyncContext context = KingdeeSyncContext.empty(); int success = 0; List failures = new ArrayList<>(); @@ -618,7 +611,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl pushOneReceivedInvoice( - planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync)); + planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, context, autoSync)); success++; } catch (RuntimeException pushException) { failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) @@ -672,20 +665,19 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl kingdeeRows, - Map apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) { + KingdeeSyncContext context, boolean autoSync) { // 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启) InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.lambdaQuery() .eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE")); @@ -699,10 +691,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl entryGroups = - kingdeeApBillSyncSupport.resolveEntryGroups(plan.details(), context); KingdeeReceivedInvoiceSyncSupport.ReceivedInvoicePushOutcome pushOutcome = - kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context); + kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), context); billNo = pushOutcome.billNo(); billPkId = pushOutcome.billId(); log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}],星瀚内码={}", receipt.getInvoiceNo(), billNo, billPkId); @@ -772,23 +746,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl relations, - Map mirrorById, - Map settlementById, - Map> detailsBySettlement) { + private ReceivedInvoicePushPlan buildReceivedInvoicePushPlan(InvoiceReceipt receipt, Map mirrorById) { String pushStatus = Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED); if (!TransportBusiConstant.APPROVED.equals(receipt.getApprovalStatus())) { - return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "仅审批通过的收票登记允许推送金蝶"); + return new ReceivedInvoicePushPlan(receipt, null, "仅审批通过的收票登记允许推送金蝶"); } if (KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(pushStatus)) { - return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "收票单已指定应付,无需重复推送"); + return new ReceivedInvoicePushPlan(receipt, null, "收票单已指定应付,无需重复推送"); } KingdeeInvoicePool mirror = receipt.getKingdeeInvoicePoolId() == null ? null : mirrorById.get(receipt.getKingdeeInvoicePoolId()); if (mirror == null) { - return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "收票登记未绑定票池镜像,请先完成同步绑定"); + return new ReceivedInvoicePushPlan(receipt, null, "收票登记未绑定票池镜像,请先完成同步绑定"); } if ("2".equals(mirror.getCheckStatus())) { - return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "金蝶发票查验不通过,请先人工核实票池发票"); + return new ReceivedInvoicePushPlan(receipt, mirror, "金蝶发票查验不通过,请先人工核实票池发票"); } - if (relations.size() != 1 || relations.get(0).getFormalSettlementId() == null) { - return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, - "金蝶不支持一张发票指定多张应付单,收票登记须恰好分摊一张应付正式结算单"); + if (receipt.getDeptId() == null) { + return new ReceivedInvoicePushPlan(receipt, mirror, "收票登记未选择结算组织,无法确定金蝶结算组织编码"); } - FormalSettlement settlement = settlementById.get(relations.get(0).getFormalSettlementId()); - if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1) - || !Objects.equals(settlement.getStatus(), 1) - || !TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus()) - || !"payable".equals(settlement.getSettlementType())) { - return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, null, "分摊结算单不是审批通过的应付正式结算单"); - } - List details = detailsBySettlement.getOrDefault(settlement.getId(), List.of()); - if (details.isEmpty()) { - return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, null, - "结算单" + settlement.getFormalSettlementNo() + "无结算明细,无法组装应付单防重键"); - } - return new ReceivedInvoicePushPlan(receipt, mirror, settlement, details, - kingdeeApBillSyncSupport.apBillSrcCode(settlement, details), null); + return new ReceivedInvoicePushPlan(receipt, mirror, null); } /** A801-3 幂等预检批量查询:未终态计划按购方单位分组,窗口内翻页匹配防重键 */ @@ -909,7 +857,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl details, String apBillSrcCode, String blockedReason) { + private record ReceivedInvoicePushPlan(InvoiceReceipt receipt, KingdeeInvoicePool mirror, String blockedReason) { + } + + // ==================== 先票后款:发票明细查询 / 认领候选 / 结算侧关系同步与指定应付 ==================== + + @Override + public List invoiceDetailQuery(String invoiceNo) { + if (StringUtil.isBlank(invoiceNo)) { + throw new ServiceException("请填写发票号码"); + } + String trimmedInvoiceNo = invoiceNo.trim(); + KingdeeInvoicePool mirror = invoicePoolMapper.selectOne(Wrappers.lambdaQuery() + .eq(KingdeeInvoicePool::getInvoiceNo, trimmedInvoiceNo)); + if (mirror != null) { + if (Func.isBlank(mirror.getItemsJson())) { + log.info("发票[{}]镜像明细为空,按票号回查 A801-1 补写", trimmedInvoiceNo); + fetchMirrorItemsFromPool(mirror); + } + List items = this.parseMirrorItems(trimmedInvoiceNo, mirror.getItemsJson()); + if (Func.isNotEmpty(items)) { + return items; + } + } + // 无镜像或补查后仍无明细:直接取窄查返回行的明细(票未登记的明细预览场景) + BfiInvoicePoolVO poolRow = queryPoolByInvoiceNo(trimmedInvoiceNo).stream() + .filter(row -> Func.isNotEmpty(row.getInvoiceNo()) && trimmedInvoiceNo.equals(row.getInvoiceNo().trim())) + .findFirst().orElse(null); + if (poolRow == null) { + throw new ServiceException("金蝶票池未查询到发票 " + trimmedInvoiceNo + ",无法查询发票明细"); + } + return Func.isEmpty(poolRow.getItems()) ? List.of() : poolRow.getItems(); + } + + @Override + public List claimCandidates(String keyword) { + String trimmedKeyword = StringUtil.isBlank(keyword) ? null : keyword.trim(); + List approved = list(Wrappers.lambdaQuery() + .eq(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.APPROVED) + .and(Func.isNotBlank(trimmedKeyword), item -> item.like(InvoiceReceipt::getInvoiceNo, trimmedKeyword) + .or().like(InvoiceReceipt::getIssuerName, trimmedKeyword) + .or().like(InvoiceReceipt::getReceiverName, trimmedKeyword)) + .orderByDesc(InvoiceReceipt::getCreateTime) + .last("LIMIT 200")); + if (approved.isEmpty()) { + return List.of(); + } + // 排除已被有效应付结算单认领的票(关系仍指向作废结算单的可重新选) + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery().in(InvoiceReceiptSettlement::getInvoiceReceiptId, + approved.stream().map(InvoiceReceipt::getId).toList())); + List claimedReceiptIds = this.claimedReceiptIds(relations); + return approved.stream().filter(receipt -> !claimedReceiptIds.contains(receipt.getId())).toList(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void syncSettlementReceiptRelations(Long settlementId, List invoiceNos) { + FormalSettlement settlement = formalSettlementMapper.selectById(settlementId); + if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) { + throw new ServiceException("正式结算单不存在,无法同步收票认领关系"); + } + List distinctNos = invoiceNos == null ? List.of() : invoiceNos.stream() + .filter(Func::isNotBlank).map(String::trim).distinct().toList(); + // 按行发票号匹配审批通过的收票登记(行锁防两结算单并发认领同一票) + List matchedReceipts = distinctNos.isEmpty() ? List.of() + : list(Wrappers.lambdaQuery() + .in(InvoiceReceipt::getInvoiceNo, distinctNos) + .eq(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.APPROVED) + .last("FOR UPDATE")); + this.checkSingleSettlementClaim(settlement, matchedReceipts); + // 重建本结算单认领关系(删旧建新,认领金额=发票金额全额认领) + settlementRelationMapper.delete(Wrappers.lambdaQuery() + .eq(InvoiceReceiptSettlement::getFormalSettlementId, settlementId)); + for (InvoiceReceipt receipt : matchedReceipts) { + InvoiceReceiptSettlement relation = new InvoiceReceiptSettlement(); + relation.setInvoiceReceiptId(receipt.getId()); + relation.setFormalSettlementId(settlementId); + relation.setFormalSettlementNo(settlement.getFormalSettlementNo()); + relation.setSettlementAmount(money(settlement.getSettlementAmount())); + relation.setReceivedInvoiceAmount(receivedAmount(settlementId, receipt.getId())); + relation.setAllocatedInvoiceAmount(money(receipt.getInvoiceAmount())); + settlementRelationMapper.insert(relation); + log.info("结算单[{}]认领收票登记[{}]:全额认领 {}", settlement.getFormalSettlementNo(), + receipt.getInvoiceNo(), relation.getAllocatedInvoiceAmount()); + } + refreshSettlementInvoiceStatus(settlementId); + log.info("结算单[{}]收票认领关系同步完成:匹配收票登记 {} 张(入参发票号 {} 个)", + settlement.getFormalSettlementNo(), matchedReceipts.size(), distinctNos.size()); + } + + @Override + public String assignClaimedReceipts(Long settlementId, String apBillSrcCode) { + if (Func.isBlank(apBillSrcCode)) { + log.warn("结算单[{}]财务应付单防重键为空,跳过收票单指定应付", settlementId); + return ""; + } + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(InvoiceReceiptSettlement::getFormalSettlementId, settlementId)); + if (relations.isEmpty()) { + log.info("结算单[{}]无认领收票登记,跳过 A302-6 指定应付", settlementId); + return ""; + } + List receipts = listByIds(relations.stream() + .map(InvoiceReceiptSettlement::getInvoiceReceiptId).filter(Objects::nonNull).distinct().toList()); + int assigned = 0; + int skipped = 0; + List failures = new ArrayList<>(); + for (InvoiceReceipt receipt : receipts) { + String pushStatus = Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED); + if (!KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(pushStatus)) { + // 未推送(unsynced/failed)或已指定(assigned)均跳过:未推送由 A801-3 回写"指定补偿"按认领关系自动补指定 + skipped++; + log.info("结算单[{}]收票登记[{}]推送状态[{}],跳过 A302-6 指定应付", settlementId, receipt.getInvoiceNo(), pushStatus); + continue; + } + try { + kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode); + applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(), + KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true); + assigned++; + log.info("结算单[{}]收票登记[{}]A302-6 指定应付成功:应付单防重键={}", settlementId, receipt.getInvoiceNo(), apBillSrcCode); + } catch (RuntimeException assignException) { + // 单笔失败不阻断结算同步:A801-3 回写"指定补偿"按认领关系自动重试 + log.warn("结算单[{}]收票登记[{}]A302-6 指定应付失败(由 A801-3 指定补偿重试)", settlementId, receipt.getInvoiceNo(), assignException); + failures.add("发票" + receipt.getInvoiceNo() + ":" + assignException.getMessage()); + } + } + String summary = "结算单收票指定应付:认领 " + receipts.size() + " 笔,指定 " + assigned + " 笔,跳过 " + skipped + " 笔"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 笔:" + String.join(";", failures); + } + log.info("{},应付单防重键={}", summary, apBillSrcCode); + return failures.isEmpty() ? "" : String.join(";", failures); + } + + /** 票池镜像明细行反序列化:空返回空集,解析异常按脏数据拦截 */ + private List parseMirrorItems(String invoiceNo, String itemsJson) { + if (Func.isBlank(itemsJson)) { + return List.of(); + } + try { + BfiInvoicePoolItemVO[] items = JsonUtil.parse(itemsJson, BfiInvoicePoolItemVO[].class); + return items == null ? List.of() : Arrays.asList(items); + } catch (RuntimeException exception) { + log.warn("发票[{}]票池镜像明细行反序列化失败", invoiceNo, exception); + throw new ServiceException("发票[" + invoiceNo + "]票池镜像明细行数据异常,请先按票号补查票池明细"); + } + } + + /** 关系表中仍指向有效应付结算单(未作废应付类型)的登记id:这些票不允许再被其他结算单认领 */ + private List claimedReceiptIds(List relations) { + if (relations.isEmpty()) { + return List.of(); + } + List settlementIds = relations.stream() + .map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + Set activeSettlementIds = settlementIds.isEmpty() ? Set.of() + : formalSettlementMapper.selectBatchIds(settlementIds).stream() + .filter(settlement -> !Objects.equals(settlement.getIsDeleted(), 1) + && Objects.equals(settlement.getStatus(), 1) + && "payable".equals(settlement.getSettlementType())) + .map(FormalSettlement::getId).collect(Collectors.toSet()); + if (activeSettlementIds.isEmpty()) { + return List.of(); + } + return relations.stream() + .filter(relation -> activeSettlementIds.contains(relation.getFormalSettlementId())) + .map(InvoiceReceiptSettlement::getInvoiceReceiptId).distinct().toList(); + } + + /** 一票一结算单校验:认领票已挂其他有效应付结算单时拦截(指向本结算单的旧关系即将重建,不参与校验) */ + private void checkSingleSettlementClaim(FormalSettlement settlement, List receipts) { + if (receipts.isEmpty()) { + return; + } + List receiptIds = receipts.stream().map(InvoiceReceipt::getId).toList(); + List otherRelations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .in(InvoiceReceiptSettlement::getInvoiceReceiptId, receiptIds) + .ne(InvoiceReceiptSettlement::getFormalSettlementId, settlement.getId())).stream() + .filter(relation -> relation.getFormalSettlementId() != null).toList(); + if (otherRelations.isEmpty()) { + return; + } + List otherSettlementIds = otherRelations.stream() + .map(InvoiceReceiptSettlement::getFormalSettlementId).distinct().toList(); + Map otherSettlements = formalSettlementMapper.selectBatchIds(otherSettlementIds).stream() + .collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, second) -> first)); + for (InvoiceReceipt receipt : receipts) { + for (InvoiceReceiptSettlement relation : otherRelations) { + if (!relation.getInvoiceReceiptId().equals(receipt.getId())) { + continue; + } + FormalSettlement other = otherSettlements.get(relation.getFormalSettlementId()); + boolean activeSettlement = other != null && !Objects.equals(other.getIsDeleted(), 1) + && Objects.equals(other.getStatus(), 1) && "payable".equals(other.getSettlementType()); + if (activeSettlement) { + throw new ServiceException("发票" + receipt.getInvoiceNo() + "已被结算单" + + other.getFormalSettlementNo() + "认领,一张收票单只能被一张付款结算单认领"); + } + } + } } // ==================== A801-3 收票结果定时回写(异常回收/指定补偿/单号补登) ==================== @@ -974,7 +1121,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl匹配与容错说明: * ① 匹配键为收票登记 id,与 A303-2 落库的 awrp_src_code 同源,按发票号匹配将永远查不到自推单据; @@ -1076,14 +1223,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl distinctSettlementRows( List rows) { Map result = new LinkedHashMap<>(); + if (rows == null || rows.isEmpty()) { + return result; + } for (InvoiceReceiptSaveRequest.SettlementRow row : rows) { if (row == null || row.getSettlementId() == null) { throw new ServiceException("正式结算单不能为空"); @@ -2016,7 +2170,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl settlementMap = lockSettlements(relations.stream() .map(InvoiceReceiptSettlement::getFormalSettlementId)