feat: 收票登记推送改先票后款终态pushed并支持明细查询/认领候选/关系同步/指定应付

This commit is contained in:
刘泉佳
2026-10-31 16:06:40 +08:00
parent bbf819d1b3
commit 02e6f06f37
3 changed files with 375 additions and 164 deletions
@@ -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<String> pushKingdee(@RequestBody List<Long> ids) {
return R.data(invoiceReceiptService.pushKingdeeReceivedInvoice(ids));
}
@@ -192,4 +194,18 @@ public class InvoiceReceiptController extends BladeController {
public R<String> syncKingdeeResult() {
return R.data(invoiceReceiptService.syncKingdeeReceivedInvoiceResult());
}
@GetMapping("/invoice-detail-query")
@ApiOperationSupport(order = 17)
@Operation(summary = "发票明细查询(先票后款:收票登记明细区块按票号带出发票明细行)")
public R<List<BfiInvoicePoolItemVO>> invoiceDetailQuery(@RequestParam String invoiceNo) {
return R.data(invoiceReceiptService.invoiceDetailQuery(invoiceNo));
}
@GetMapping("/claim-candidates")
@ApiOperationSupport(order = 18)
@Operation(summary = "结算单认领收票单候选(先票后款:审批通过且未被有效应付结算单认领)")
public R<List<InvoiceReceipt>> claimCandidates(@RequestParam(required = false) String keyword) {
return R.data(invoiceReceiptService.claimCandidates(keyword));
}
}
@@ -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<InvoiceReceipt> {
void voidBill(InvoiceReceiptStatusRequest request);
/**
* 批量推送金蝶收票单(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付)。
* <p>逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、恰好分摊一张应付结算单)→
* A102-4 预检财务应付单已生成 → A801-3 幂等预检(命中跳过保存续推指定)→ A303-2 保存 →
* 立即 A302-6 指定应付;成功置 assigned 并锁单,失败置 failed 提示人工处理后重推。
* 批量推送金蝶收票单(A303-2 收票单批量保存,先票后款:推送即终态 pushed)。
* <p>逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、已选结算组织)→
* A801-3 幂等预检(命中跳过保存)→ A303-2 保存(分录=发票明细行)→ 终态 pushed;
* A302-6 指定应付由付款结算单侧在财务应付单(A102-2)生成后按认领关系执行
*
* @param ids 收票登记id列表
* @return 执行摘要(逐单失败原因)
@@ -117,7 +118,7 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
/**
* 自动推送路径批量推送金蝶收票单(审批通过事件 / 定时重推专用):
* 前置拦截(未绑定镜像/查验未通过/前置应付单未生成等)一并置 failed + 原因,纳入定时重推循环
* 前置拦截(未绑定镜像/查验未通过/未选结算组织等)一并置 failed + 原因,纳入定时重推循环
*
* @param ids 收票登记id列表
* @param autoSync 自动推送标记(前置拦截置 failed)
@@ -125,6 +126,44 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
*/
String pushKingdeeReceivedInvoice(List<Long> ids, boolean autoSync);
/**
* 发票明细查询(先票后款:收票登记明细区块按票号带出):读票池镜像 items_json,
* 镜像无明细按票号回查 A801-1 补写后重读;票未登记时直接取窄查返回行明细
*
* @param invoiceNo 发票号码
* @return 发票明细行
*/
List<BfiInvoicePoolItemVO> invoiceDetailQuery(String invoiceNo);
/**
* 结算单认领收票单候选(先票后款路径A/B 选票数据源):审批通过 + 未被有效应付结算单认领,
* 关键字匹配发票号/销方/购方,最多 200 条
*
* @param keyword 关键字(发票号/销方/购方),空不过滤
* @return 候选收票登记
*/
List<InvoiceReceipt> claimCandidates(String keyword);
/**
* 结算单收票认领关系同步(先票后款:结算单保存/认领后由结算单侧调用):
* 按行发票号匹配审批通过收票登记,一票一结算单校验(已挂其他有效应付结算单拦截),
* 删旧建新重建本结算单认领关系(认领金额=发票金额全额)并刷新结算单收票状态
*
* @param settlementId 正式结算单id
* @param invoiceNos 认领发票号码集合(结算单发票信息行发票号)
*/
void syncSettlementReceiptRelations(Long settlementId, List<String> 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<InvoiceReceipt> {
/**
* 定时回写金蝶收票单结果(A801-3):金蝶侧删除/红冲/作废 → 回收为 failed 提示人工(不自动作废登记);
* pushed(保存成功后中断)→ 指定补偿;assigned 仅补登缺失的收票单号
* pushed 且已认领 → 指定补偿(重试引擎),未认领跳过;assigned 仅补登缺失的收票单号
*
* @return 执行摘要
*/
@@ -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<InvoiceReceiptMap
KingdeeInvoicePool invoice = persistMirrorFromRemote(request.getInvoiceNo());
assertInvoiceUnused(invoice, entity.getId());
// 先票后款链路:settlements 不传=不挂结算单(认领关系只由付款结算单侧维护);传了走历史兼容链路
Map<Long, InvoiceReceiptSaveRequest.SettlementRow> requestedRows = distinctSettlementRows(
request.getSettlements());
Map<Long, FormalSettlement> settlementMap = lockSettlements(requestedRows.keySet().stream().sorted().toList());
List<FormalSettlement> settlements = requestedRows.keySet().stream().map(settlementMap::get).toList();
assertCompatible(settlements);
// 历史兼容链路(传 settlements)保留原整体一致性校验;先票后款链路(不传)跳过
if (!requestedRows.isEmpty()) {
assertCompatible(settlements);
}
for (Map.Entry<Long, InvoiceReceiptSaveRequest.SettlementRow> entry : requestedRows.entrySet()) {
FormalSettlement settlement = settlementMap.get(entry.getKey());
BigDecimal allocated = nonNegative(entry.getValue().getAllocatedInvoiceAmount(), "分摊发票金额");
@@ -418,13 +421,32 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
entity.setCurrentProcessor(AuthUtil.getUserName());
}
copyInvoiceInformation(entity, invoice);
FormalSettlement first = settlements.get(0);
entity.setProjectId(first.getProjectId());
entity.setProjectName(first.getProjectName());
entity.setDeptId(first.getDeptId());
entity.setDeptName(first.getDeptName());
entity.setPayerName(first.getPayerName());
entity.setPayeeName(first.getPayeeName());
if (requestedRows.isEmpty()) {
// 先票后款:结算组织=页面手选部门(合同承办部门口径);付款方=受票单位、收款方=开票单位
if (request.getDeptId() == null) {
throw new ServiceException("请选择结算组织");
}
entity.setDeptId(request.getDeptId());
entity.setDeptName(request.getDeptName());
entity.setPayerName(invoice.getReceiverName());
entity.setPayeeName(invoice.getIssuerName());
// 往来户=发票销售方金蝶供应商代码(A901-1),保存时尽力回填,推送前强校验缺失阻断
if (Func.isBlank(entity.getSupplierCode())) {
String supplierCode = kingdeeApBillSyncSupport.queryCustomerCode(invoice.getIssuerName());
entity.setSupplierCode(supplierCode);
if (Func.isBlank(supplierCode)) {
log.warn("收票登记[{}]销售方[{}]未建档金蝶供应商编码,推送前须先维护客商档案", invoice.getInvoiceNo(), invoice.getIssuerName());
}
}
} else {
FormalSettlement first = settlements.get(0);
entity.setProjectId(first.getProjectId());
entity.setProjectName(first.getProjectName());
entity.setDeptId(first.getDeptId());
entity.setDeptName(first.getDeptName());
entity.setPayerName(first.getPayerName());
entity.setPayeeName(first.getPayeeName());
}
entity.setPhone(limit(Func.isNotEmpty(request.getPhone()) ? request.getPhone() : invoice.getPhone(),
50, "电话"));
entity.setCustomerEmails(normalizeEmails(Func.isNotEmpty(request.getCustomerEmails())
@@ -435,12 +457,17 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
entity.setRemark(limit(request.getRemark(), 200, "备注"));
saveOrUpdate(entity);
settlementRelationMapper.delete(Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getInvoiceReceiptId, entity.getId()));
saveRelations(entity.getId(), requestedRows, settlementMap);
Set<Long> refreshIds = new LinkedHashSet<>(oldSettlementIds);
refreshIds.addAll(settlementMap.keySet());
refreshIds.forEach(this::refreshSettlementInvoiceStatus);
if (requestedRows.isEmpty()) {
// 未传 settlements:不动既有分摊关系(先票后款链路,关系只由结算单侧重建维护)
log.info("收票登记[{}]保存未关联结算单(先票后款链路),既有分摊关系保持不动", entity.getInvoiceNo());
} else {
settlementRelationMapper.delete(Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getInvoiceReceiptId, entity.getId()));
saveRelations(entity.getId(), requestedRows, settlementMap);
Set<Long> 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<InvoiceReceiptMap
}
/**
* 批量推送收票登记至金蝶(图05 环节②③:A303-2 收票单批量保存 + A302-6 指定应付)。
* 批量推送收票登记至金蝶(A303-2 收票单批量保存,先票后款:推送即终态 pushed,A302-6 指定应付移交付款结算单侧)。
* <p>
* 编排流程:参数去重校验 → 批量组装推送计划(分摊/镜像/结算单/结算明细一次取回防 N+1)→
* A102-4 应付单已生成预检 → A801-3 幂等预检(金蝶已有收票单则跳过保存续推指定)→
* 逐单推送(单笔失败不中断整批)→ 汇总成功/失败笔数返回
* 编排流程:参数去重校验 → 批量组装推送计划(镜像一次取回防 N+1)→
* A801-3 幂等预检(金蝶已有收票单则跳过保存)→ 逐单推送(单笔失败不中断整批)→ 汇总成功/失败笔数返回
*
* @param ids 收票登记主键集合
* @param autoSync 自动推送路径(审批通过事件/定时重推):前置拦截置 failed + 原因纳入重推循环;
@@ -561,56 +587,23 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
if (receipts.size() != distinctIds.size()) {
throw new ServiceException("部分收票登记不存在或已删除");
}
// 批量组装推送计划:分摊关系/镜像/结算单/结算明细一次取回,避免逐单查库 N+1
// 1) 收票登记与正式结算单的分摊关系(按登记id分组),用于"一票一应付"校验及防重键取数来源
Map<Long, List<InvoiceReceiptSettlement>> relationsByReceipt = settlementRelationMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.in(InvoiceReceiptSettlement::getInvoiceReceiptId, distinctIds)
.orderByAsc(InvoiceReceiptSettlement::getCreateTime)).stream()
.collect(Collectors.groupingBy(InvoiceReceiptSettlement::getInvoiceReceiptId));
// 2) 票池镜像(A801-1 同步落库的发票数据),提供明细行 itemsJson、票种编码、购方单位等推送必填字段
// 批量组装推送计划:票池镜像一次取回防 N+1(先票后款:不再依赖分摊结算单/结算明细与 A102-4 应付单预检)
List<Long> mirrorIds = receipts.stream().map(InvoiceReceipt::getKingdeeInvoicePoolId)
.filter(Objects::nonNull).distinct().toList();
Map<Long, KingdeeInvoicePool> mirrorById = mirrorIds.isEmpty() ? Map.of()
: invoicePoolMapper.selectBatchIds(mirrorIds).stream()
.collect(Collectors.toMap(KingdeeInvoicePool::getId, Function.identity(), (first, second) -> first));
// 3) 分摊关系指向的正式结算单,后续校验须为审批通过的应付类型结算单
List<Long> settlementIds = relationsByReceipt.values().stream().flatMap(List::stream)
.map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList();
Map<Long, FormalSettlement> settlementById = settlementIds.isEmpty() ? Map.of()
: formalSettlementMapper.selectBatchIds(settlementIds).stream()
.collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, second) -> first));
// 4) 应付结算单的明细行(按行号升序),用于组装 A302-6 指定应付的防重键 apBillSrcCode
List<Long> payableSettlementIds = settlementById.values().stream()
.filter(settlement -> "payable".equals(settlement.getSettlementType()))
.map(FormalSettlement::getId).toList();
Map<Long, List<FormalSettlementDetail>> detailsBySettlement = payableSettlementIds.isEmpty() ? Map.of()
: formalSettlementDetailMapper.selectList(Wrappers.<FormalSettlementDetail>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<Long, ReceivedInvoicePushPlan> 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<String> apSrcCodes = planByReceipt.values().stream()
.filter(plan -> plan.blockedReason() == null)
.map(ReceivedInvoicePushPlan::apBillSrcCode).filter(Func::isNotBlank).distinct().toList();
Map<String, String> apBillNoBySrcCode = apSrcCodes.isEmpty() ? Map.of()
: kingdeeApBillSyncSupport.queryApBillExistNos(apSrcCodes);
log.info("推送金蝶收票单 A102-4 预检:待校验防重键 {} 个,已生成财务应付单 {} 个", apSrcCodes.size(), apBillNoBySrcCode.size());
// A801-3 幂等预检:按购方单位(接口必填)分组回看窗口翻页,本地按防重键匹配(金蝶已存在则跳过保存续推指定)
// A801-3 幂等预检:按购方单位(接口必填)分组回看窗口翻页,本地按防重键匹配(金蝶已存在则跳过保存)
Map<String, BfiReceivedInvoiceVO> kingdeeRowByInvoiceNo = queryKingdeeReceivedInvoiceRows(planByReceipt);
log.info("推送金蝶收票单 A801-3 预检:金蝶已存在收票单 {} 笔,命中单据将跳过保存续推指定", kingdeeRowByInvoiceNo.size());
log.info("推送金蝶收票单 A801-3 预检:金蝶已存在收票单 {} 笔,命中单据将跳过保存", kingdeeRowByInvoiceNo.size());
KingdeeSyncContext context = KingdeeSyncContext.empty();
int success = 0;
List<String> failures = new ArrayList<>();
@@ -618,7 +611,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
try {
// 逐单单据级事务:推送方法首行 FOR UPDATE 串行化同一登记的并发推送,单笔失败只回滚自身不中断整批
transactionTemplate.executeWithoutResult(status -> 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<InvoiceReceiptMap
}
/**
* 单张收票登记推送编排:前置校验 → A102-4 预检 → A801-3 幂等预检 → A303-2 保存 → pushed 落库 →
* A302-6 指定应付 → assigned;保存/指定阶段失败置 failed,断点可凭防重键续推。
* 单张收票登记推送编排:前置校验 → A801-3 幂等预检 → A303-2 保存 → pushed 终态落库(先票后款:
* A302-6 指定应付由付款结算单侧在财务应付单生成后按认领关系执行)。
* 自动推送路径(autoSync=true)前置拦截一并置 failed + 原因,纳入定时重推循环;
* 手动批量路径前置拦截保持不动状态(逐单报因),避免人工误触把可自助修复的单据打成失败
*
* @param plan 预检组装的推送计划
* @param kingdeeRows A801-3 幂等预检命中的金蝶收票单(发票号码 -&gt; 行)
* @param apBillNoBySrcCode A102-4 已生成的财务应付单(防重键 -&gt; 单据编号)
* @param context 金蝶同步上下文(合同/客商编码缓存)
* @param autoSync 自动推送路径标记(前置拦截置 failed)
* @param plan 预检组装的推送计划
* @param kingdeeRows A801-3 幂等预检命中的金蝶收票单(发票号码 -&gt; 行)
* @param context 金蝶同步上下文(客商编码缓存)
* @param autoSync 自动推送路径标记(前置拦截置 failed)
* @return 推送结果描述
*/
private String pushOneReceivedInvoice(ReceivedInvoicePushPlan plan, Map<String, BfiReceivedInvoiceVO> kingdeeRows,
Map<String, String> apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) {
KingdeeSyncContext context, boolean autoSync) {
// 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启)
InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.<InvoiceReceipt>lambdaQuery()
.eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE"));
@@ -699,10 +691,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + "已推送金蝶,幂等跳过";
}
// 计划内登记实体以行锁后最新数据替换(并发下推送状态可能已被其他事务改写)
plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.settlement(), plan.details(),
plan.apBillSrcCode(), plan.blockedReason());
log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(),
receipt.getKingdeePushStatus(), plan.apBillSrcCode());
plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.blockedReason());
log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={}", receipt.getInvoiceNo(), receipt.getId(),
receipt.getKingdeePushStatus());
if (Func.isNotBlank(plan.blockedReason())) {
log.warn("收票登记[{}]推送金蝶收票单拦截:{}", receipt.getInvoiceNo(), plan.blockedReason());
// 自动推送路径:前置不满足置 failed + 原因纳入重推循环("已指定应付"的良性跳过不改状态)
@@ -713,19 +704,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
throw new ServiceException(plan.blockedReason());
}
String apBillSrcCode = plan.apBillSrcCode();
if (!apBillNoBySrcCode.containsKey(apBillSrcCode)) {
log.warn("收票登记[{}]推送金蝶收票单拦截:结算单[{}]未生成财务应付单(防重键={})", receipt.getInvoiceNo(),
plan.settlement().getFormalSettlementNo(), apBillSrcCode);
// 自动推送路径:前置应付单未生成置 failed + 原因,上游结算单就绪后由重推任务自动续推
if (autoSync) {
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("请先同步金蝶应付单:结算单" + plan.settlement().getFormalSettlementNo() + "未生成财务应付单"), true);
}
throw new ServiceException("请先同步金蝶应付单:结算单" + plan.settlement().getFormalSettlementNo()
+ "未生成财务应付单");
}
String billNo;
String billPkId;
BfiReceivedInvoiceVO kingdeeRow = kingdeeRows.get(String.valueOf(receipt.getId()));
@@ -745,22 +723,18 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
// 幂等预检命中行携带星瀚收票单内码,回填补齐(A902 附件上传定位用)
billPkId = kingdeeRow.getId() == null ? receipt.getKingdeeReceivedInvoiceId()
: String.valueOf(kingdeeRow.getId());
log.info("收票登记[{}]金蝶收票单已生成(单据编号[{}]),幂等跳过 A303-2 保存续推指定", receipt.getInvoiceNo(), billNo);
log.info("收票登记[{}]金蝶收票单已生成(单据编号[{}]),幂等跳过 A303-2 保存", receipt.getInvoiceNo(), billNo);
} else {
try {
// 镜像缺票种编码(入镜像早于字段上线或滑出增量窗口):按票号窄查 A801-1 回填补写;
// 明细分录已与应付单同源取自结算货物费用行,票池明细行仅入池存档、不再作为分录取数来源
if (Func.isBlank(plan.mirror().getInvoiceTypeCode())) {
log.info("收票登记[{}]镜像缺票种编码,按票号回填 A801-1", receipt.getInvoiceNo());
// 镜像缺票种编码/明细行(入镜像早于字段上线或滑出增量窗口):按票号窄查 A801-1 回填补写;
// 先票后款链路明细分录逐行取发票明细行(镜像 items_json),必须齐备
if (Func.isBlank(plan.mirror().getInvoiceTypeCode()) || Func.isBlank(plan.mirror().getItemsJson())) {
log.info("收票登记[{}]镜像缺票种编码/明细行,按票号回填 A801-1", receipt.getInvoiceNo());
fetchMirrorItemsFromPool(plan.mirror());
}
log.info("收票登记[{}]开始 A303-2 推送金蝶收票单", receipt.getInvoiceNo());
ContractManage contract = kingdeeApBillSyncSupport.requireContract(plan.settlement().getContractId(), context);
// 明细分录与应付单 A102-2 同源同口径:复用同一结算明细解析结果,结算明细行+结算合计费用项各一条
List<KingdeeApBillSyncSupport.BfiEntryGroup> 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<InvoiceReceiptMap
throw pushException;
}
}
// 保存成功先落单号+星瀚内码+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false);
// 先票后款:A303-2 保存成功即终态 pushed(A302-6 指定应付由付款结算单侧在应付单生成后执行,此处不再立即指定)
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, true);
// A303-2 成功即联带上传附件(登记附件 + 镜像发票文件经 A801-2 下载回传):
// 失败只记附件同步状态,不阻塞 A302-6 指定应付,也不阻塞同批其他登记,重推自动补传
// 失败只记附件同步状态,不阻塞同批其他登记,重推自动补传
syncAttachmentsAfterPush(receipt, plan, billPkId);
try {
log.info("收票登记[{}]开始 A302-6 指定应付:应付单防重键={}", receipt.getInvoiceNo(), apBillSrcCode);
kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode);
} catch (RuntimeException assignException) {
log.warn("收票登记[{}]A302-6 指定应付失败:{}", receipt.getInvoiceNo(), assignException.getMessage());
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("金蝶收票单指定应付失败:" + assignException.getMessage()), true);
throw assignException;
}
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
log.info("收票登记[{}]推送金蝶收票单完成:金蝶收票单[{}]已指定应付单", receipt.getInvoiceNo(), billNo);
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单";
log.info("收票登记[{}]推送金蝶收票单完成:金蝶收票单[{}]已保存(指定应付由付款结算单侧执行)", receipt.getInvoiceNo(), billNo);
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已保存收票单";
}
/**
@@ -843,46 +807,30 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/**
* 推送前置组装:状态机、绑定、查验、一票一应付、应付单防重键;不满足项写入 blockedReason(不拦截整批,逐单报因)
* 推送前置组装:状态机、绑定、查验、结算组织;不满足项写入 blockedReason(不拦截整批,逐单报因)。
* 先票后款链路:不再校验分摊结算单(一票一应付的指定前置移至结算单侧),往来户供应商代码缺失由
* 推送时实时查 A901-1 兜底,不做前置拦截
*/
private ReceivedInvoicePushPlan buildReceivedInvoicePushPlan(InvoiceReceipt receipt,
List<InvoiceReceiptSettlement> relations,
Map<Long, KingdeeInvoicePool> mirrorById,
Map<Long, FormalSettlement> settlementById,
Map<Long, List<FormalSettlementDetail>> detailsBySettlement) {
private ReceivedInvoicePushPlan buildReceivedInvoicePushPlan(InvoiceReceipt receipt, Map<Long, KingdeeInvoicePool> 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<FormalSettlementDetail> 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<InvoiceReceiptMap
}
/**
* 推送/回写结果落库:回写金蝶收票单号、星瀚收票单内码、推送状态、失败原因与时间;写终态(assigned/failed)时记同步流水
* 推送/回写结果落库:回写金蝶收票单号、星瀚收票单内码、推送状态、失败原因与时间;写终态(pushed/assigned/failed)时记同步流水
*/
private void applyReceivedInvoicePushResult(InvoiceReceipt receipt, String receivedInvoiceNo, String receivedInvoiceId,
String pushStatus, String pushMsg, boolean writeRecord) {
@@ -923,7 +871,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
updateById(receipt);
if (writeRecord) {
record(receipt.getId(), "sync", "推送金蝶收票单", receipt.getApprovalStatus(), receipt.getApprovalStatus(),
Func.isNotBlank(pushMsg) ? pushMsg : "金蝶收票单[" + Func.toStr(receivedInvoiceNo) + "]已生成并指定应付",
Func.isNotBlank(pushMsg) ? pushMsg : "金蝶收票单[" + Func.toStr(receivedInvoiceNo) + "]已生成",
receipt.getKingdeeReceivedInvoiceNo());
}
}
@@ -949,17 +897,216 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/**
* 收票单推送预检计划(blockedReason 非空表示前置拦截,不进入推送流程)
* 收票单推送预检计划(blockedReason 非空表示前置拦截,不进入推送流程)。
* 先票后款链路:分摊结算单/防重键不再参与推送前置,指定应付由结算单侧驱动
*
* @param receipt 收票登记
* @param mirror 绑定的票池镜像(可为 null)
* @param settlement 唯一分摊的应付正式结算单(可为 null)
* @param details 结算单结算明细(防重键组装与 A303-2 分录解析共用,可为 null)
* @param apBillSrcCode 财务应付单防重键(无法组装时为 null)
* @param blockedReason 前置拦截原因
*/
private record ReceivedInvoicePushPlan(InvoiceReceipt receipt, KingdeeInvoicePool mirror,
FormalSettlement settlement, List<FormalSettlementDetail> details, String apBillSrcCode, String blockedReason) {
private record ReceivedInvoicePushPlan(InvoiceReceipt receipt, KingdeeInvoicePool mirror, String blockedReason) {
}
// ==================== 先票后款:发票明细查询 / 认领候选 / 结算侧关系同步与指定应付 ====================
@Override
public List<BfiInvoicePoolItemVO> invoiceDetailQuery(String invoiceNo) {
if (StringUtil.isBlank(invoiceNo)) {
throw new ServiceException("请填写发票号码");
}
String trimmedInvoiceNo = invoiceNo.trim();
KingdeeInvoicePool mirror = invoicePoolMapper.selectOne(Wrappers.<KingdeeInvoicePool>lambdaQuery()
.eq(KingdeeInvoicePool::getInvoiceNo, trimmedInvoiceNo));
if (mirror != null) {
if (Func.isBlank(mirror.getItemsJson())) {
log.info("发票[{}]镜像明细为空,按票号回查 A801-1 补写", trimmedInvoiceNo);
fetchMirrorItemsFromPool(mirror);
}
List<BfiInvoicePoolItemVO> 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<InvoiceReceipt> claimCandidates(String keyword) {
String trimmedKeyword = StringUtil.isBlank(keyword) ? null : keyword.trim();
List<InvoiceReceipt> approved = list(Wrappers.<InvoiceReceipt>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<InvoiceReceiptSettlement> relations = settlementRelationMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery().in(InvoiceReceiptSettlement::getInvoiceReceiptId,
approved.stream().map(InvoiceReceipt::getId).toList()));
List<Long> 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<String> invoiceNos) {
FormalSettlement settlement = formalSettlementMapper.selectById(settlementId);
if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) {
throw new ServiceException("正式结算单不存在,无法同步收票认领关系");
}
List<String> distinctNos = invoiceNos == null ? List.of() : invoiceNos.stream()
.filter(Func::isNotBlank).map(String::trim).distinct().toList();
// 按行发票号匹配审批通过的收票登记(行锁防两结算单并发认领同一票)
List<InvoiceReceipt> matchedReceipts = distinctNos.isEmpty() ? List.of()
: list(Wrappers.<InvoiceReceipt>lambdaQuery()
.in(InvoiceReceipt::getInvoiceNo, distinctNos)
.eq(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.APPROVED)
.last("FOR UPDATE"));
this.checkSingleSettlementClaim(settlement, matchedReceipts);
// 重建本结算单认领关系(删旧建新,认领金额=发票金额全额认领)
settlementRelationMapper.delete(Wrappers.<InvoiceReceiptSettlement>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<InvoiceReceiptSettlement> relations = settlementRelationMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getFormalSettlementId, settlementId));
if (relations.isEmpty()) {
log.info("结算单[{}]无认领收票登记,跳过 A302-6 指定应付", settlementId);
return "";
}
List<InvoiceReceipt> receipts = listByIds(relations.stream()
.map(InvoiceReceiptSettlement::getInvoiceReceiptId).filter(Objects::nonNull).distinct().toList());
int assigned = 0;
int skipped = 0;
List<String> 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<BfiInvoicePoolItemVO> 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<Long> claimedReceiptIds(List<InvoiceReceiptSettlement> relations) {
if (relations.isEmpty()) {
return List.of();
}
List<Long> settlementIds = relations.stream()
.map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList();
Set<Long> 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<InvoiceReceipt> receipts) {
if (receipts.isEmpty()) {
return;
}
List<Long> receiptIds = receipts.stream().map(InvoiceReceipt::getId).toList();
List<InvoiceReceiptSettlement> otherRelations = settlementRelationMapper.selectList(
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.in(InvoiceReceiptSettlement::getInvoiceReceiptId, receiptIds)
.ne(InvoiceReceiptSettlement::getFormalSettlementId, settlement.getId())).stream()
.filter(relation -> relation.getFormalSettlementId() != null).toList();
if (otherRelations.isEmpty()) {
return;
}
List<Long> otherSettlementIds = otherRelations.stream()
.map(InvoiceReceiptSettlement::getFormalSettlementId).distinct().toList();
Map<Long, FormalSettlement> 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<InvoiceReceiptMap
* 金蝶收票单结果回写主体(A801-3):定时轮询推送状态为 pushed/assigned 且未作废的收票登记,
* 按购方单位分组在时间窗口内反查金蝶收票单结果,一体推进三类补偿场景:
* ① 异常回收——金蝶侧发票状态异常时回收推送状态为 failed 并记同步流水,提示人工删除/解除指定后重推;
* ② 指定补偿——pushed(保存成功后中断、指定未完成)按预组装的应付单防重键重发 A302-6 指定应付;
* ② 指定补偿——pushed 且已认领(有应付单防重键)按预组装的应付单防重键重发 A302-6 指定应付(重试引擎),未认领跳过;
* ③ 单号补登——assigned 终态缺失金蝶收票单号(保存成功但响应丢失的历史数据)时仅补登单号,不改变状态。
* <p>匹配与容错说明:
* ① 匹配键为收票登记 id,与 A303-2 落库的 awrp_src_code 同源,按发票号匹配将永远查不到自推单据;
@@ -1076,14 +1223,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return summary;
}
/** pushed 单据指定补偿:金蝶收票单存在且状态正常,按发票防重键+应付防重键重发 A302-6 */
/**
* pushed 单据指定补偿(先票后款"指定补偿"重试引擎):已认领(应付单防重键组装成功)→ 重发 A302-6 → assigned;
* 未认领 → 跳过不再置 failed(指定应付由结算单侧驱动,认领后由结算单同步/发票登记触发,此处未指定不属异常)
*/
private void reassignPushedReceivedInvoice(InvoiceReceipt receipt, BfiReceivedInvoiceVO row, String apBillSrcCode) {
if (StringUtil.isBlank(apBillSrcCode)) {
applyReceivedInvoicePushResult(receipt, row.getBillno(),
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("收票单指定应付补偿失败:收票登记分摊关系不满足一票一应付,请人工核对"), true);
throw new ServiceException("收票单指定应付补偿失败:分摊关系不满足一票一应付");
log.info("收票登记[{}]金蝶收票单[{}]已推送但未认领应付结算单,跳过指定补偿(认领后由结算单侧补指定)",
receipt.getInvoiceNo(), Func.toStrWithEmpty(row.getBillno(), receipt.getKingdeeReceivedInvoiceNo()));
return;
}
try {
kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode);
@@ -1573,7 +1721,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
/**
* 镜像票面是否有变化(轻量比对,ponytail: 只比对"会变且影响业务"的字段,
* 不搞人工字段清单——清单漏新增列即静默不同步是旧版指纹的坑):
* 流水号(金蝶侧标识)、价税合计、税额、剩余可用金额、金额使用状态。
* 流水号(金蝶侧标识)、明细行 items_json、价税合计、税额、剩余可用金额、金额使用状态。
* BigDecimal 比对用 compareTo(13.00 与 13.0 等价);空值视为变化跳过重写(与 MP 非空字段更新策略一致)
*
* @param existing 库中既有镜像
@@ -1585,6 +1733,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|| !Objects.equals(existing.getAmountUseStatus(), filled.getAmountUseStatus())) {
return true;
}
// 明细行变化而票面金额未变(单价变总价不变、商品编码修正、行拆合)也要重写:
// items_json 是先票后款链路 A303-2 分录唯一取数来源;源侧无明细返回 null 维持库中原值,不参与比对
if (filled.getItemsJson() != null && !Objects.equals(existing.getItemsJson(), filled.getItemsJson())) {
return true;
}
return amountChanged(existing.getInvoiceAmount(), filled.getInvoiceAmount())
|| amountChanged(existing.getTaxAmount(), filled.getTaxAmount())
|| amountChanged(existing.getAvailableAmount(), filled.getAvailableAmount());
@@ -1852,14 +2005,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
if (request == null) {
throw new ServiceException("收票登记数据不能为空");
}
if (request.getSettlements() == null || request.getSettlements().isEmpty()) {
throw new ServiceException("请至少选择一张应付正式结算单");
}
// 先票后款链路:不再强制关联结算单(settlements 兼容历史入参);不传时结算组织手选必选在 saveDraft 校验
}
private Map<Long, InvoiceReceiptSaveRequest.SettlementRow> distinctSettlementRows(
List<InvoiceReceiptSaveRequest.SettlementRow> rows) {
Map<Long, InvoiceReceiptSaveRequest.SettlementRow> 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<InvoiceReceiptMap
.eq(InvoiceReceiptSettlement::getInvoiceReceiptId, entity.getId())
.orderByAsc(InvoiceReceiptSettlement::getCreateTime));
if (relations.isEmpty()) {
throw new ServiceException("请至少选择一张应付正式结算单");
// 先票后款链路:无分摊关系(关系由结算单侧维护),跳过分摊校验
log.info("收票登记[{}]无分摊关系,跳过分摊校验(先票后款链路)", entity.getInvoiceNo());
return;
}
Map<Long, FormalSettlement> settlementMap = lockSettlements(relations.stream()
.map(InvoiceReceiptSettlement::getFormalSettlementId)