!4 fix: 结算调整单重推拦截报错补充具体结算单号

Merge pull request !4 from mindless/feature/20261016-settlement-adjustment-kingdee
This commit is contained in:
2026-10-19 06:58:25 +00:00
committed by Gitee
43 changed files with 2828 additions and 100 deletions
@@ -32,6 +32,9 @@ public final class KingdeeLockConstant {
/** 来源操作类型:单据删除(解锁回调携带 DELETE 时 WLTMS 废除源单并还原关联数据) */
public static final String OPERATION_DELETE = "DELETE";
/** 来源操作类型:结算调整重推(结算调整单重推后解锁原结算单旧锁记录,非金蝶回调) */
public static final String OPERATION_ADJUST_REPUSH = "ADJUST_REPUSH";
/** 系统标识:WLTMS(运输管理系统,即本系统) */
public static final String SYSTEM_WLTMS = "WLTMS";
@@ -7,6 +7,8 @@ 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.core.tool.utils.Func;
import org.springblade.transport.listener.KingdeeApprovalSyncListener;
import org.springblade.transport.pojo.dto.SettlementAdjustmentSaveRequest;
import org.springblade.transport.pojo.dto.SettlementAdjustmentStatusRequest;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
@@ -33,7 +35,20 @@ public class SettlementAdjustmentController extends BladeController {
@PostMapping("/save") public R<Long> save(@RequestBody SettlementAdjustmentSaveRequest request) { return R.data(service.saveDraft(request)); }
@PostMapping("/remove") public R remove(@RequestParam Long id) { service.removeDraft(id); return R.success("删除成功"); }
@PostMapping("/submit") public R submit(@RequestBody SettlementAdjustmentStatusRequest request) { service.submit(request); return R.success("提交成功"); }
@PostMapping("/approve") public R approve(@RequestBody SettlementAdjustmentStatusRequest request) { service.approve(request); return R.success("审批通过"); }
@PostMapping("/approve") public R approve(@RequestBody SettlementAdjustmentStatusRequest request) {
String hint = service.approve(request);
// 未推送场景自动推送失败原因以警告文案返回(data=false 供前端区分提示样式)
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
if (Func.isNotBlank(syncFailure)) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (Func.isNotBlank(hint)) {
return R.data(Boolean.TRUE, "审批通过;" + hint);
}
return R.success("审批通过");
}
@PostMapping("/return") public R returnBill(@RequestBody SettlementAdjustmentStatusRequest request) { service.returnBill(request); return R.success("已驳回"); }
@PostMapping("/repush") public R<String> repush(@RequestParam Long id) { return R.data(service.repush(id)); }
@GetMapping("/kingdee-writeoff-precheck") public R<Map<String, Object>> kingdeeWriteOffPrecheck(@RequestParam Long formalSettlementId) { return R.data(service.kingdeeWriteOffPrecheck(formalSettlementId)); }
@PostMapping("/sync-kingdee") public R<String> syncKingdee(@RequestParam Long id) { return R.data(service.syncKingdee(id)); }
}
@@ -15,6 +15,9 @@ import org.apache.ibatis.annotations.Param;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 结算单项目预付核销 Mapper
@@ -37,4 +40,12 @@ public interface SettlementProjectAdvanceWriteOffMapper extends BaseMapper<Settl
BigDecimal sumWriteOffBySettlement(@Param("settlementType") String settlementType,
@Param("settlementId") Long settlementId);
/**
* 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用,避免逐单 N+1)。
*
* @return settlementId / totalAmount 两列
*/
List<Map<String, Object>> sumWriteOffBySettlementIds(@Param("settlementType") String settlementType,
@Param("settlementIds") Collection<Long> settlementIds);
}
@@ -32,4 +32,16 @@
AND settlement_id = #{settlementId}
</select>
<select id="sumWriteOffBySettlementIds" resultType="java.util.HashMap">
SELECT settlement_id AS settlementId, COALESCE(SUM(write_off_amount), 0) AS totalAmount
FROM blade_settlement_project_advance_write_off
WHERE is_deleted = 0
AND settlement_type = #{settlementType}
AND settlement_id IN
<foreach collection="settlementIds" item="settlementId" open="(" separator="," close=")">
#{settlementId}
</foreach>
GROUP BY settlement_id
</select>
</mapper>
@@ -24,6 +24,8 @@ import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest;
import org.springblade.transport.pojo.dto.FormalSettlementBatchPaymentRequest;
import org.springblade.transport.pojo.dto.FormalSettlementInvoiceClaimRequest;
import java.util.Map;
/**
* 正式结算单服务
*
@@ -93,4 +95,29 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
String applyPayment(FormalSettlementPaymentRequest request);
List<String> applyPayments(FormalSettlementBatchPaymentRequest request);
void claimInvoices(FormalSettlementInvoiceClaimRequest request);
/**
* 结算调整重推:克隆原正式结算单为新单(含明细、明细费项、汇总费项、来源预结算、附件、汇率),
* 新单置审批通过、未同步金蝶,来源原单号指向原单;下游关联(开票申请/收款认领/对账)不迁移。
* 不推送金蝶,由调用方在事务外调用 syncKingdee 推送
*
* @param sourceSettlementId 原正式结算单id
* @return 新单id与「原明细/原费项 -&gt; 新明细/新费项」id映射(供调整明细应用到新单)
*/
CloneResult cloneFormalSettlement(Long sourceSettlementId);
/**
* 结算调整重推收尾:原单标记「已调整」、旧锁解锁、追加重推变更记录(不改原单业务数据)
*
* @param settlementId 原正式结算单id
* @param adjustmentNo 结算调整单号
* @param newSettlementNo 重推生成的新单号
*/
void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo);
/**
* 克隆结果:新单id + 原明细id-&gt;新明细id + 原费项id-&gt;新费项id
*/
record CloneResult(Long settlementId, Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
}
}
@@ -60,6 +60,17 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
*/
void checkEditable(String sourceBillType, Long sourceBillId);
/**
* 结算调整重推解锁:把源单当前「锁定中」的锁记录标记为 unlocked(非金蝶回调,仅本地解锁留痕),
* 供原结算单被调整重推生成新单后解除旧锁使用;新单另行落新锁记录
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillId WLTMS 业务主键
* @param operator 操作人
* @return 解锁的记录数
*/
int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator);
/**
* 批量查源单当前锁状态(供列表/详情页填充展示标识):
* 每源单取最新一条非终态锁记录(locked/failed),unlocked 为终态不返回(前端视为无锁/已解锁)
@@ -14,7 +14,9 @@ import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 结算单项目预付核销服务
@@ -24,7 +26,7 @@ import java.util.List;
public interface ISettlementProjectAdvanceWriteOffService {
/**
* 按合同查询可选项目预付(审批通过且仍有可核销余额)。
* 按合同查询可选项目预付(审批通过、金蝶已付款且仍有可核销余额)。
*/
List<ProjectAdvanceWriteOffOptionVO> listOptions(Long contractId, String excludeSettlementType,
Long excludeSettlementId);
@@ -39,6 +41,11 @@ public interface ISettlementProjectAdvanceWriteOffService {
*/
BigDecimal sumBySettlement(String settlementType, Long settlementId);
/**
* 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用)。
*/
Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds);
/**
* 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。
*/
@@ -50,4 +57,14 @@ public interface ISettlementProjectAdvanceWriteOffService {
*/
void deleteBySettlement(String settlementType, Long settlementId);
/**
* 删除指定结算单集合上的核销明细(核销行转移/结算单作废联动),返回删除行数。
*/
int deleteBySettlementIds(String settlementType, Collection<Long> settlementIds);
/**
* 按付款申请删除其核销明细(付款申请作废联动),返回受影响的正式结算单id。
*/
List<Long> deleteByPaymentApplicationIds(Collection<Long> paymentApplicationIds);
}
@@ -46,11 +46,11 @@ public record KingdeeSyncContext(Map<Long, ContractManage> contractCache,
Map<Long, List<FormalSettlementDetail>> settlementDetailCache,
Map<Long, FormalSettlement> settlementCache,
Map<Long, CustomerReceiptAccount> receiptAccountCache,
Map<String, String> feeCategoryByName) {
Map<String, String> feeCategoryByName, Map<Long, String> paymentNoCache) {
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
public static KingdeeSyncContext empty() {
return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(),
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
}
}
@@ -33,12 +33,14 @@ import org.springblade.transport.mapper.InvoiceApplicationMapper;
import org.springblade.transport.mapper.InvoiceApplicationSettlementMapper;
import org.springblade.transport.mapper.PreSettlementDetailMapper;
import org.springblade.transport.mapper.PreSettlementDetailFeeMapper;
import org.springblade.transport.mapper.PreSettlementAdvanceMapper;
import org.springblade.transport.mapper.PreSettlementMapper;
import org.springblade.transport.mapper.PreSettlementSummaryFeeMapper;
import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper;
import org.springblade.transport.mapper.PaymentApplicationSettlementMapper;
import org.springblade.transport.mapper.ReceiptClaimSettlementMapper;
import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeBfiConstant;
@@ -69,6 +71,7 @@ import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice;
import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.PreSettlement;
import org.springblade.transport.pojo.entity.PreSettlementAdvance;
import org.springblade.transport.pojo.entity.PreSettlementDetail;
import org.springblade.transport.pojo.entity.PreSettlementDetailFee;
import org.springblade.transport.pojo.entity.PreSettlementSummaryFee;
@@ -79,9 +82,11 @@ import org.springblade.transport.pojo.entity.ReceiptClaimSettlement;
import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.FormalSettlementVO;
import org.springblade.transport.pojo.vo.PreSettlementVO;
import org.springblade.transport.pojo.vo.SettlementAdvancePaymentVO;
import org.springblade.transport.pojo.vo.SettlementAdjustmentVO;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceReceiptService;
@@ -113,6 +118,8 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -162,6 +169,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
private final PreSettlementSummaryFeeMapper preSummaryFeeMapper;
private final PreSettlementDetailMapper preDetailMapper;
private final PreSettlementDetailFeeMapper preDetailFeeMapper;
private final PreSettlementAdvanceMapper preSettlementAdvanceMapper;
private final SettlementProjectAdvanceWriteOffMapper projectAdvanceWriteOffMapper;
private final ReceivablePayableDetailMapper receivablePayableMapper;
private final ReceivablePayableCargoFeeMapper receivablePayableCargoFeeMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
@@ -207,7 +216,13 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
if (ids.isEmpty()) wrapper.eq(FormalSettlement::getId, -1L); else wrapper.in(FormalSettlement::getId, ids);
}
IPage<FormalSettlement> result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime));
IPage<FormalSettlementVO> voPage = result.convert(this::toVO);
// 本页应付结算单的项目预付核销合计一次查询,避免逐行 toVO 重算汇总时的 N+1
Map<Long, BigDecimal> writeOffAmounts = this.projectAdvanceWriteOffService.sumBySettlementIds(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL,
result.getRecords().stream()
.filter(item -> "payable".equals(item.getSettlementType()))
.map(FormalSettlement::getId).toList());
IPage<FormalSettlementVO> voPage = result.convert(item -> this.toVO(item, writeOffAmounts));
fillAttachmentSyncStatus(voPage.getRecords());
fillKingdeeLockStatus(voPage.getRecords());
return voPage;
@@ -311,6 +326,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
vo.setIssuedInvoices(listIssuedInvoices(id));
vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, id));
vo.setAdvancePayments(this.listAdvancePayments(sources, vo.getProjectAdvanceWriteOffs()));
Set<Long> relationApplicationIds = paymentApplicationSettlementMapper.selectList(
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
.select(PaymentApplicationSettlement::getPaymentApplicationId)
@@ -333,7 +349,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
vo.setAdjustments(settlementAdjustmentMapper.selectList(Wrappers.<SettlementAdjustment>lambdaQuery()
.eq(SettlementAdjustment::getFormalSettlementId, id)
.orderByDesc(SettlementAdjustment::getCreateTime)).stream()
.map(item -> toAdjustmentVO(item, settlement.getKingdeeBillNo())).toList());
.map(this::toAdjustmentVO).toList());
vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.<FormalSettlementChangeRecord>lambdaQuery()
.eq(FormalSettlementChangeRecord::getFormalSettlementId, id)
.orderByDesc(FormalSettlementChangeRecord::getChangeTime)));
@@ -564,12 +580,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
applySummaryRequest(settlement.getId(), request.getSummaryFees());
refreshSettlementAmount(settlement);
rebuildInvoices(settlement, request.getInvoices());
// 预结算转正式/调整来源:先转移来源预结算的项目预付核销行(释放可核销占用),replaceWriteOffs 的余额校验才能通过
this.projectAdvanceWriteOffService.deleteBySettlementIds(
TransportBusiConstant.SETTLEMENT_BILL_PRE, request.getSourcePreSettlementIds());
projectAdvanceWriteOffService.replaceWriteOffs(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL,
settlement.getId(),
settlement.getContractId(),
settlement.getSettlementAmount(),
request.getProjectAdvanceWriteOffs());
// 核销行计入收付汇总(refreshSettlementAmount 内的刷新发生在核销落库之前,覆盖不到核销行)
this.refreshPaymentSummary(settlement.getId());
if (!creating && !restoring) {
saveChange(settlement.getId(), "结算单基本信息", null, "调整",
"保存正式结算单" + settlement.getFormalSettlementNo(), request.getRemark());
@@ -624,6 +645,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
settlement.setCurrentNode("已作废");
settlement.setVoidReason(required(limit(request.getReason(), 200), "作废原因"));
updateById(settlement);
// 联动删除项目预付核销行,释放可核销占用并避免残留记录
this.projectAdvanceWriteOffService.deleteBySettlementIds(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, List.of(settlement.getId()));
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "作废正式结算单", request.getReason());
}
@@ -643,6 +667,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.set(FormalSettlement::getKingdeeBillNo, null)
.set(FormalSettlement::getKingdeeBillId, null)
.set(FormalSettlement::getSyncedTime, null));
// 联动删除项目预付核销行,释放可核销占用并避免残留记录
this.projectAdvanceWriteOffService.deleteBySettlementIds(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, List.of(id));
// 变更流水:回调无登录态,操作人取金蝶报文(兜底 system,与 saveChange 同款)
FormalSettlementChangeRecord record = new FormalSettlementChangeRecord();
record.setFormalSettlementId(id);
@@ -655,6 +682,118 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
changeRecordMapper.insert(record);
}
@Override
@Transactional(rollbackFor = Exception.class)
public CloneResult cloneFormalSettlement(Long sourceSettlementId) {
FormalSettlement source = existing(sourceSettlementId);
FormalSettlement clone = Objects.requireNonNull(BeanUtil.copyProperties(source, FormalSettlement.class));
// 新单头:新号、直接审批通过、未同步金蝶、来源原单号;不动原单
resetAuditFields(clone);
clone.setFormalSettlementNo(nextNo(source.getSettlementType()));
clone.setApprovalStatus(TransportBusiConstant.APPROVED);
clone.setCurrentNode("审批通过");
clone.setCurrentProcessor(AuthUtil.getUserName());
clone.setApprovedTime(LocalDateTime.now());
clone.setKingdeeBillNo(null);
clone.setKingdeeBillId(null);
clone.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED);
clone.setSyncedTime(null);
clone.setVoidReason(null);
clone.setOriginFormalSettlementNo(source.getFormalSettlementNo());
clone.setAdjustStatus(null);
// 下游关联(开票申请/收款认领/对账)不迁移:发票/收付款口径归零,新单为全新独立单
clone.setInvoiceStatus("unreceived");
clone.setPaymentStatus("unpaid");
clone.setInvoiceAmount(BigDecimal.ZERO.setScale(2));
clone.setAppliedPaymentAmount(BigDecimal.ZERO.setScale(2));
clone.setPaidAmount(BigDecimal.ZERO.setScale(2));
clone.setRemainingPayableAmount(money(source.getSettlementAmount()));
save(clone);
// 明细与明细费项克隆,记录 原id->新id 映射(调整明细应用到新单时按映射定位)
Map<Long, Long> detailIdMap = new HashMap<>();
Map<Long, Long> feeIdMap = new HashMap<>();
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, sourceSettlementId)
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
for (FormalSettlementDetail detail : details) {
FormalSettlementDetail cloneDetail = Objects.requireNonNull(BeanUtil.copyProperties(detail, FormalSettlementDetail.class));
resetAuditFields(cloneDetail);
cloneDetail.setFormalSettlementId(clone.getId());
detailMapper.insert(cloneDetail);
detailIdMap.put(detail.getId(), cloneDetail.getId());
for (FormalSettlementDetailFee fee : detailFeeMapper.selectList(Wrappers.<FormalSettlementDetailFee>lambdaQuery()
.eq(FormalSettlementDetailFee::getFormalSettlementDetailId, detail.getId())
.orderByAsc(FormalSettlementDetailFee::getLineNo))) {
FormalSettlementDetailFee cloneFee = Objects.requireNonNull(BeanUtil.copyProperties(fee, FormalSettlementDetailFee.class));
resetAuditFields(cloneFee);
cloneFee.setFormalSettlementDetailId(cloneDetail.getId());
detailFeeMapper.insert(cloneFee);
feeIdMap.put(fee.getId(), cloneFee.getId());
}
}
// 汇总费用项克隆
for (FormalSettlementSummaryFee summaryFee : listSummaryFees(sourceSettlementId)) {
FormalSettlementSummaryFee cloneFee = Objects.requireNonNull(BeanUtil.copyProperties(summaryFee, FormalSettlementSummaryFee.class));
resetAuditFields(cloneFee);
cloneFee.setFormalSettlementId(clone.getId());
summaryFeeMapper.insert(cloneFee);
}
// 来源预结算重建(接续原单来源,不重复锁定 PreSettlement.formalSettlementNo)
for (FormalSettlementSource relation : sourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
.eq(FormalSettlementSource::getFormalSettlementId, sourceSettlementId))) {
FormalSettlementSource cloneRelation = Objects.requireNonNull(BeanUtil.copyProperties(relation, FormalSettlementSource.class));
resetAuditFields(cloneRelation);
cloneRelation.setFormalSettlementId(clone.getId());
sourceMapper.insert(cloneRelation);
}
// 核销行迁移:克隆到新单并删除原单行,避免预付可核销额度双占(原单为调整存档,收付口径以新单为准)
List<SettlementProjectAdvanceWriteOff> writeOffRows = this.projectAdvanceWriteOffMapper.selectList(
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, TransportBusiConstant.SETTLEMENT_BILL_FORMAL)
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, sourceSettlementId)
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0));
for (SettlementProjectAdvanceWriteOff writeOffRow : writeOffRows) {
SettlementProjectAdvanceWriteOff cloneWriteOff = Objects.requireNonNull(
BeanUtil.copyProperties(writeOffRow, SettlementProjectAdvanceWriteOff.class));
resetAuditFields(cloneWriteOff);
cloneWriteOff.setSettlementId(clone.getId());
this.projectAdvanceWriteOffMapper.insert(cloneWriteOff);
}
this.projectAdvanceWriteOffService.deleteBySettlementIds(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, List.of(sourceSettlementId));
this.refreshPaymentSummaries(List.of(sourceSettlementId, clone.getId()));
log.info("结算调整重推克隆正式结算单:原单[{}] -> 新单[{}](明细{}条,核销迁移{}条)",
source.getFormalSettlementNo(), clone.getFormalSettlementNo(), details.size(), writeOffRows.size());
return new CloneResult(clone.getId(), detailIdMap, feeIdMap);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo) {
existing(settlementId);
// 原单只加标记,业务字段不动
update(Wrappers.<FormalSettlement>lambdaUpdate()
.eq(FormalSettlement::getId, settlementId)
.set(FormalSettlement::getAdjustStatus, "adjusted"));
kingdeeBillLockService.unlockByAdjustRepush(KingdeeLockConstant.SOURCE_SETTLEMENT, settlementId, AuthUtil.getUserName());
saveChange(settlementId, "结算单基本信息", null, "调整",
"结算调整单" + Func.toStr(adjustmentNo) + "重推生成新单" + Func.toStr(newSettlementNo) + ",原单标记已调整", null);
}
/** 重推/克隆复用:清空审计与主键字段,交由 MP 自动填充 */
private void resetAuditFields(org.springblade.core.mp.base.BaseEntity entity) {
entity.setId(null);
entity.setCreateUser(null);
entity.setCreateDept(null);
entity.setCreateTime(null);
entity.setUpdateUser(null);
entity.setUpdateTime(null);
entity.setStatus(null);
entity.setIsDeleted(null);
}
@Override
public String syncKingdee(Long id) {
String billNo;
@@ -719,6 +858,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.ne(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED)
.set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED));
if (updated > 0) {
// 结算调整单回填:未推送场景审批通过后自动推送失败,调整单置 failed 供「推送金蝶」重试
settlementAdjustmentMapper.update(null, Wrappers.<SettlementAdjustment>lambdaUpdate()
.eq(SettlementAdjustment::getFormalSettlementId, id)
.eq(SettlementAdjustment::getApprovalStatus, TransportBusiConstant.APPROVED)
.set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED));
// 失败原因写变更记录留档(推送事务已回滚,此处独立事务);原因过长截断留档,不抛错
String failReason = Func.toStrWithEmpty(reason, "未知异常");
saveChange(id, "结算单基本信息", null, "调整",
@@ -824,7 +968,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
@Override
@Transactional(rollbackFor = Exception.class)
public String doSyncKingdee(Long id) {
FormalSettlement settlement = existing(id);
FormalSettlement settlement = lockedExisting(id);
if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许同步金蝶");
// 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据);星瀚内码由附件补传分支反查回填
if ("synced".equals(settlement.getKingdeeSyncStatus())) return settlement.getKingdeeBillNo();
@@ -870,6 +1014,12 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
settlement.setSyncedTime(LocalDateTime.now());
updateById(settlement);
saveChange(settlement.getId(), "结算单基本信息", null, "调整", changeDesc, "");
// 结算调整单回填:未推送场景审批通过后自动推送成功,回填挂在本单的已审批调整单(重推场景调整单已改挂新单,不受影响)
settlementAdjustmentMapper.update(null, Wrappers.<SettlementAdjustment>lambdaUpdate()
.eq(SettlementAdjustment::getFormalSettlementId, settlement.getId())
.eq(SettlementAdjustment::getApprovalStatus, TransportBusiConstant.APPROVED)
.set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED)
.set(SettlementAdjustment::getKingdeeBillNo, billNo));
log.info("正式结算单[{}]同步金蝶完成:settlementType={},billNo={},billPkId={}",
settlement.getFormalSettlementNo(), settlement.getSettlementType(), billNo, billPkId);
return billNo;
@@ -1584,6 +1734,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
Collectors.reducing(BigDecimal.ZERO,
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)))
: Map.of();
// 项目预付核销合计(仅应付结算单需要,一次 group-by 避免逐单查询)
Map<Long, BigDecimal> writeOffAmountsBySettlement = hasPayable
? this.projectAdvanceWriteOffService.sumBySettlementIds(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settledIds)
: Map.of();
for (FormalSettlement settlement : settlements) {
List<PaymentApplicationSettlement> relations = relationsBySettlement
@@ -1599,7 +1754,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
PaymentSummary summary = calculatePaymentSummary(settlement, new PaymentSummaryData(
relations, directApplications, relationStatuses,
sourcesBySettlement.getOrDefault(settlement.getId(), List.of()),
receiptClaimPaidBySettlement.getOrDefault(settlement.getId(), BigDecimal.ZERO)));
receiptClaimPaidBySettlement.getOrDefault(settlement.getId(), BigDecimal.ZERO),
writeOffAmountsBySettlement));
settlement.setAppliedPaymentAmount(summary.appliedAmount());
settlement.setPaidAmount(summary.paidAmount());
settlement.setRemainingPayableAmount(money(settlement.getSettlementAmount())
@@ -1919,9 +2075,16 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
private FormalSettlementVO toVO(FormalSettlement entity) {
return this.toVO(entity, null);
}
/**
* @param writeOffAmounts 结算单id -&gt; 项目预付核销合计(列表页批量传入避免 N+1;null 表示按需单笔查询)
*/
private FormalSettlementVO toVO(FormalSettlement entity, Map<Long, BigDecimal> writeOffAmounts) {
FormalSettlementVO vo = FormalSettlementWrapper.build().entityVO(entity);
vo.setInvoiceStatus(invoiceStatus(entity.getInvoiceAmount(), entity.getSettlementAmount()));
PaymentSummary summary = calculatePaymentSummary(entity);
PaymentSummary summary = this.calculatePaymentSummary(entity, writeOffAmounts);
vo.setAppliedPaymentAmount(summary.appliedAmount());
vo.setPaidAmount(summary.paidAmount());
vo.setRemainingPayableAmount(money(entity.getSettlementAmount()).subtract(summary.paidAmount())
@@ -1931,6 +2094,10 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
private PaymentSummary calculatePaymentSummary(FormalSettlement settlement) {
return this.calculatePaymentSummary(settlement, (Map<Long, BigDecimal>) null);
}
private PaymentSummary calculatePaymentSummary(FormalSettlement settlement, Map<Long, BigDecimal> writeOffAmounts) {
List<PaymentApplicationSettlement> relations = paymentApplicationSettlementMapper.selectList(
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
.eq(PaymentApplicationSettlement::getFormalSettlementId, settlement.getId())
@@ -1948,7 +2115,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.in(PaymentApplication::getId, relationApplicationIds).eq(PaymentApplication::getIsDeleted, 0))
.stream().collect(Collectors.toMap(PaymentApplication::getId, PaymentApplication::getApprovalStatus));
return calculatePaymentSummary(settlement, new PaymentSummaryData(relations, applications,
relationStatuses, null, null));
relationStatuses, null, null, writeOffAmounts));
}
/**
@@ -1981,6 +2148,13 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add));
paidAmount = paidAmount.add(sources.stream().map(FormalSettlementSource::getAdvancePaidAmount)
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add));
// 项目预付核销计入已付/已申请(核销行底层预付必已付款,核销额即实付抵扣)
BigDecimal writeOffAmount = data.writeOffAmounts() != null
? data.writeOffAmounts().getOrDefault(settlement.getId(), BigDecimal.ZERO)
: this.projectAdvanceWriteOffService.sumBySettlement(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settlement.getId());
appliedAmount = appliedAmount.add(this.money(writeOffAmount));
paidAmount = paidAmount.add(this.money(writeOffAmount));
}
if ("receivable".equals(settlement.getSettlementType())) {
paidAmount = data.receiptClaimPaid() != null ? data.receiptClaimPaid()
@@ -2016,22 +2190,91 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
* @param relationStatuses 付款申请id → 审批状态
* @param sources 预收来源行(仅应付结算单;null 表示由纯计算按需查询)
* @param receiptClaimPaid 收款认领分摊聚合(仅应收结算单;null 表示由纯计算按需查询)
* @param writeOffAmounts 结算单id → 项目预付核销合计(仅应付结算单;null 表示由纯计算按需查询)
*/
private record PaymentSummaryData(List<PaymentApplicationSettlement> relations,
List<PaymentApplication> directApplications, Map<Long, String> relationStatuses,
List<FormalSettlementSource> sources, BigDecimal receiptClaimPaid) {
List<FormalSettlementSource> sources, BigDecimal receiptClaimPaid,
Map<Long, BigDecimal> writeOffAmounts) {
}
private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity, String kingdeeBillNo) {
private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity) {
SettlementAdjustmentVO vo = Objects.requireNonNull(
BeanUtil.copyProperties(entity, SettlementAdjustmentVO.class));
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
vo.setApprovalStatusName(approvalStatusName(entity.getApprovalStatus()));
vo.setSettlementTypeName("receivable".equals(entity.getSettlementType()) ? "应收" : "应付");
vo.setKingdeeBillNo(kingdeeBillNo);
return vo;
}
/**
* 统一预付付款记录:进度预付(来源预结算台账)+ 项目预付核销(回填底层付款申请实付)。
* createTime 统一取底层付款申请创建时间(项目预付核销行已回填 paymentCreateTime,进度预付按单号反查)。
*/
private List<SettlementAdvancePaymentVO> listAdvancePayments(List<FormalSettlementSource> sources,
List<SettlementProjectAdvanceWriteOff> writeOffs) {
List<SettlementAdvancePaymentVO> records = new ArrayList<>();
List<Long> preSettlementIds = sources.stream().map(FormalSettlementSource::getPreSettlementId)
.filter(Objects::nonNull).distinct().toList();
if (!preSettlementIds.isEmpty()) {
List<PreSettlementAdvance> advances = this.preSettlementAdvanceMapper.selectList(
Wrappers.<PreSettlementAdvance>lambdaQuery()
.eq(PreSettlementAdvance::getIsDeleted, 0)
.ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)
.in(PreSettlementAdvance::getPreSettlementId, preSettlementIds));
// 进度预付行的展示时间取底层付款申请创建时间(advanceNo 即付款申请单号)
List<String> advanceNos = advances.stream().map(PreSettlementAdvance::getAdvanceNo)
.filter(Func::isNotEmpty).distinct().toList();
Map<String, Date> applicationTimes = advanceNos.isEmpty() ? Map.of()
: this.paymentApplicationMapper.selectList(Wrappers.<PaymentApplication>lambdaQuery()
.select(PaymentApplication::getPaymentNo, PaymentApplication::getCreateTime)
.eq(PaymentApplication::getIsDeleted, 0)
.in(PaymentApplication::getPaymentNo, advanceNos))
.stream().filter(item -> item.getCreateTime() != null)
.collect(Collectors.toMap(PaymentApplication::getPaymentNo,
PaymentApplication::getCreateTime, (first, second) -> first));
for (PreSettlementAdvance advance : advances) {
SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO();
record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROGRESS_ADVANCE);
record.setPaymentNo(advance.getAdvanceNo());
record.setAppliedAmount(advance.getAppliedAmount());
record.setPaidAmount(advance.getPaidAmount());
record.setBillStatus(advance.getBillStatus());
record.setKingdeeBillNo(advance.getKingdeeAdvanceNo());
record.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser()));
record.setCreateTime(applicationTimes.getOrDefault(advance.getAdvanceNo(), advance.getCreateTime()));
records.add(record);
}
}
if (Func.isNotEmpty(writeOffs)) {
List<Long> applicationIds = writeOffs.stream()
.map(SettlementProjectAdvanceWriteOff::getPaymentApplicationId)
.filter(Objects::nonNull).distinct().toList();
Map<Long, PaymentApplication> applications = applicationIds.isEmpty() ? Map.of()
: this.paymentApplicationMapper.selectBatchIds(applicationIds).stream()
.collect(Collectors.toMap(PaymentApplication::getId, Function.identity(),
(first, second) -> first));
for (SettlementProjectAdvanceWriteOff writeOff : writeOffs) {
PaymentApplication application = applications.get(writeOff.getPaymentApplicationId());
SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO();
record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE);
record.setPaymentNo(writeOff.getPaymentNo());
record.setAppliedAmount(writeOff.getWriteOffAmount());
record.setPaidAmount(application == null ? null : application.getPaidAmount());
record.setBillStatus(application == null ? null : application.getApprovalStatus());
record.setKingdeeBillNo(writeOff.getKingdeeBillNo());
record.setCreateUserName(UserCache.getUserRealName(application == null
? writeOff.getCreateUser() : application.getCreateUser()));
record.setCreateTime(writeOff.getPaymentCreateTime() != null
? writeOff.getPaymentCreateTime() : writeOff.getCreateTime());
records.add(record);
}
}
records.sort(Comparator.comparing(SettlementAdvancePaymentVO::getCreateTime,
Comparator.nullsLast(Comparator.reverseOrder())));
return records;
}
private String approvalStatusName(String value) {
if (value == null) {
return "";
@@ -2069,6 +2312,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
return entity;
}
/** 行锁读:同一单据并发推送串行化(锁随 doSyncKingdee 事务提交释放,后者读到已同步状态幂等返回) */
private FormalSettlement lockedExisting(Long id) {
FormalSettlement entity = baseMapper.selectOne(Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getId, id).last("FOR UPDATE"));
if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("正式结算单不存在");
return entity;
}
private FormalSettlement editable(Long id) {
FormalSettlement entity = existing(id);
if (!TransportBusiConstant.DRAFT.equals(entity.getApprovalStatus()) && !TransportBusiConstant.RETURNED.equals(entity.getApprovalStatus())) throw new ServiceException("仅草稿或已驳回的正式结算单允许编辑");
@@ -38,8 +38,6 @@ import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO;
@@ -701,7 +699,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
@Override
@Transactional(rollbackFor = Exception.class)
public String doSyncKingdee(Long id) {
InvoiceApplication entity = existing(id);
InvoiceApplication entity = lockedExisting(id);
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶");
// 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
@@ -803,12 +801,10 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
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.getKingdeeDeptCode())) {
throw new ServiceException("推送金蝶开票申请失败:申请部门[" + Func.toStr(entity.getDeptId()) + "]未维护金蝶组织编码,请检查部门数据");
}
// 金蝶组织编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码
String kingdeeOrgNumber = kingdeeApBillSyncSupport.kingdeeOrgNumber(entity.getProjectId(), KingdeeSyncContext.empty());
// 金蝶组织编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码;申请部门编码:项目业务部门自身金蝶编码(与付款申请单部门.编码同源,共用上下文缓存)
KingdeeSyncContext kingdeeContext = KingdeeSyncContext.empty();
String kingdeeOrgNumber = kingdeeApBillSyncSupport.kingdeeOrgNumber(entity.getProjectId(), kingdeeContext);
String kingdeeDeptNumber = kingdeeApBillSyncSupport.kingdeeDeptNumber(entity.getProjectId(), kingdeeContext);
CustomerArchive customer = entity.getReceiverCustomerId() == null ? null : customerArchiveMapper.selectById(entity.getReceiverCustomerId());
if (customer == null || Func.isBlank(customer.getCustomerCode())) {
throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试");
@@ -839,7 +835,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
+ "]无商品行,无法组装分录");
}
BfiInvoiceApplySaveDTO param = this.buildInvoiceApplyParam(entity, sheetBillNo, seller, salerAddr, salerBank,
dept, kingdeeOrgNumber, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items);
kingdeeDeptNumber, kingdeeOrgNumber, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items);
log.info("金蝶开票申请推送:开票申请[{}]发票张[{}],billno=[{}],价税合计=[{}],分录{}条",
entity.getApplicationNo(), sheet.getSheetNo(), sheetBillNo, sheet.getInvoiceAmount(), items.size());
BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param);
@@ -892,14 +888,14 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
* @param firstArSrcCode 上游来源单据号:首张关联结算单的金蝶应收单防重键 awrp_src_code(多张结算单取第一张)
*/
private BfiInvoiceApplySaveDTO buildInvoiceApplyParam(InvoiceApplication entity, String billNo,
CustomerArchive seller, String salerAddr, String salerBank, Dept dept, String orgNumber, CustomerArchive customer,
CustomerArchive seller, String salerAddr, String salerBank, String deptNumber, String orgNumber, CustomerArchive customer,
String firstArSrcCode, BigDecimal sheetAmount, List<BfiInvoiceApplySaveEntryDTO> items) {
BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO();
// ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ====================
// 【必填】组织.编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码
param.setOrgidNumber(orgNumber);
// 【必填】申请部门.编码:申请部门 deptId → 金蝶部门编码
param.setAwrpOrgfieldNumber(dept.getKingdeeDeptCode());
// 【必填】申请部门.编码:项目业务部门金蝶编码(与付款申请单 awrp_department_number 同源)
param.setAwrpOrgfieldNumber(deptNumber);
// 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号)
param.setBillno(billNo);
// 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss
@@ -1669,6 +1665,14 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
return entity;
}
/** 行锁读:同一单据并发推送串行化(锁随 doSyncKingdee 事务提交释放,后者读到已同步状态幂等返回) */
private InvoiceApplication lockedExisting(Long id) {
InvoiceApplication entity = baseMapper.selectOne(Wrappers.<InvoiceApplication>lambdaQuery()
.eq(InvoiceApplication::getId, id).last("FOR UPDATE"));
if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("开票申请不存在");
return entity;
}
private InvoiceApplication editable(Long id) {
InvoiceApplication entity = existing(id);
if (!List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) throw new ServiceException("当前状态不可编辑");
@@ -90,6 +90,7 @@ import org.springblade.transport.wrapper.InvoiceReceiptWrapper;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.io.IOException;
import java.io.InputStream;
@@ -151,6 +152,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private final CustomerInvoiceInfoMapper customerInvoiceInfoMapper;
private final IBfiService bfiService;
private final BfiProperties bfiProperties;
/** 逐单推送事务模板:批量编排逐单开短事务,行锁跨远程调用,单笔失败只回滚自身不中断整批 */
private final TransactionTemplate transactionTemplate;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeReceivedInvoiceSyncSupport kingdeeReceivedInvoiceSyncSupport;
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
@@ -608,12 +611,16 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
List<String> failures = new ArrayList<>();
for (InvoiceReceipt receipt : receipts) {
try {
pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync);
// 逐单单据级事务:推送方法首行 FOR UPDATE 串行化同一登记的并发推送,单笔失败只回滚自身不中断整批
transactionTemplate.executeWithoutResult(status -> pushOneReceivedInvoice(
planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync));
success++;
} catch (RuntimeException pushException) {
failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId()))
+ ":" + pushException.getMessage());
log.warn("推送金蝶收票单失败,收票登记id={}", receipt.getId(), pushException);
// 单笔事务已回滚,事务外独立补落"推送失败"状态,纳入定时重推(与付款/开票失败口径统一)
markReceivedInvoicePushFailed(receipt, planByReceipt.get(receipt.getId()), pushException, autoSync);
}
}
String summary = "推送金蝶收票单完成:本批 " + receipts.size() + " 笔,成功 " + success + " 笔";
@@ -674,7 +681,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
*/
private String pushOneReceivedInvoice(ReceivedInvoicePushPlan plan, Map<String, BfiReceivedInvoiceVO> kingdeeRows,
Map<String, String> apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) {
InvoiceReceipt receipt = plan.receipt();
// 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启)
InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.<InvoiceReceipt>lambdaQuery()
.eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE"));
if (receipt == null || Objects.equals(receipt.getIsDeleted(), 1)) {
throw new ServiceException("收票登记不存在");
}
// 锁后重查推送状态:并发场景另一事务已完成推送则幂等跳过,不再重复调金蝶
if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())
|| KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(receipt.getKingdeePushStatus())) {
log.info("收票登记[{}]金蝶收票单已推送(状态[{}]),幂等跳过", receipt.getInvoiceNo(), receipt.getKingdeePushStatus());
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());
if (Func.isNotBlank(plan.blockedReason())) {
@@ -765,6 +786,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单";
}
/**
* 单笔推送失败后事务外独立补落"推送失败"状态:事务版推送中的失败状态写随本单事务回滚,
* 由批量编排的 catch 在事务外重写,纳入定时重推循环;手动前置拦截路径保持不动状态(仅逐单报因)
*
* @param receipt 收票登记(批量预取实体)
* @param plan 推送计划
* @param pushException 推送异常
* @param autoSync 自动推送路径标记
*/
private void markReceivedInvoicePushFailed(InvoiceReceipt receipt, ReceivedInvoicePushPlan plan,
RuntimeException pushException, boolean autoSync) {
if (plan.blockedReason() != null && !autoSync) {
return;
}
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("推送金蝶收票单失败:" + pushException.getMessage()), true);
}
/**
* 附件联带上传(A902):A303-2 保存成功(含幂等预检命中)后调用,上传登记附件与镜像发票文件;
* billPkId 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度
@@ -251,6 +251,28 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator) {
List<KingdeeBillLock> lockedRecords = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
.eq(KingdeeBillLock::getSourceBillId, sourceBillId)
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED));
if (Func.isEmpty(lockedRecords)) {
return 0;
}
LocalDateTime unlockTime = LocalDateTime.now();
for (KingdeeBillLock lockRecord : lockedRecords) {
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
lockRecord.setOperationType(KingdeeLockConstant.OPERATION_ADJUST_REPUSH);
lockRecord.setUnlockTime(unlockTime);
lockRecord.setNotifyOperator(Func.isEmpty(operator) ? "system" : operator);
}
this.updateBatchById(lockedRecords);
log.info("结算调整重推解锁旧锁:源单[{}:{}],解锁{}张,操作人={}", sourceBillType, sourceBillId, lockedRecords.size(), operator);
return lockedRecords.size();
}
@Override
public void checkEditable(String sourceBillType, Long sourceBillId) {
long lockedCount = this.count(Wrappers.<KingdeeBillLock>lambdaQuery()
@@ -309,8 +331,11 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
}
row.setKingdeeUnlockTime(unlockRecord.getUnlockTime());
row.setKingdeeUnlockOperator(unlockRecord.getNotifyOperator());
row.setKingdeeUnlockOperationName(KingdeeLockConstant.OPERATION_DELETE.equals(unlockRecord.getOperationType())
? "金蝶删单" : "金蝶锁单");
row.setKingdeeUnlockOperationName(switch (Func.toStr(unlockRecord.getOperationType())) {
case KingdeeLockConstant.OPERATION_DELETE -> "金蝶删单";
case KingdeeLockConstant.OPERATION_ADJUST_REPUSH -> "结算调整重推";
default -> "金蝶锁单";
});
}
}
@@ -494,6 +494,11 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
updateById(entity);
// 联动作废提前建档的预结算预付记录,避免残留记录阻断预结算作废
voidPreSettlementAdvance(entity);
// 联动删除项目预付核销行,避免结算单已付汇总虚高
List<Long> writeOffSettlementIds = this.projectAdvanceWriteOffService.deleteByPaymentApplicationIds(List.of(entity.getId()));
if (!writeOffSettlementIds.isEmpty()) {
formalSettlementService.refreshPaymentSummaries(writeOffSettlementIds);
}
refreshFormalSettlement(entity);
}
@@ -589,6 +594,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
@Transactional(rollbackFor = Exception.class)
public String doSyncKingdee(Long id, KingdeeSyncContext context) {
PaymentApplication entity = lockedPayment(id);
// 单据号入上下文:批量同步失败提示用单号定位单据(雪花id用户无法对应),不再额外查库
context.paymentNoCache().put(id, entity.getPaymentNo());
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的付款申请允许生成金蝶单据");
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
|| KingdeeBfiConstant.KINGDEE_STATUS_PAYING.equals(entity.getKingdeeStatus())
@@ -791,7 +798,10 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
// 经自代理逐条调用:单据级事务,单条失败仅回滚自身且不中断整批
result.add(self.syncKingdee(id, context));
} catch (Exception exception) {
result.add("付款申请[" + id + "]同步失败:" + exception.getMessage());
// 失败提示用付款申请单号定位单据:单号已在推送时随上下文缓存,无需回查
String paymentNo = context.paymentNoCache().get(id);
String billNo = Func.isBlank(paymentNo) ? String.valueOf(id) : paymentNo;
result.add("付款申请[" + billNo + "]同步失败:" + exception.getMessage());
}
}
return result;
@@ -1218,8 +1228,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
param.setAwrpSrcSys(bfiProperties.getSrcSys());
// 结算组织.编码
param.setSettleorgNumber(orgNumber);
// 部门.编码
param.setAwrpDepartmentNumber(orgNumber);
// 部门.编码:项目业务部门自身的金蝶编码(结算/申请/付款组织仍传上溯平台公司编码)
param.setAwrpDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract.getProjectId(), context));
// 【必填】单据类型.编码
param.setBilltypeNumber(bfiProperties.getPayApply().getBilltype());
// 【必填】申请日期:yyyy-MM-dd HH:mm:ss(各接口统一格式)
@@ -1636,42 +1646,73 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private void syncPreSettlementAdvance(PaymentApplication entity, String billStatus, BigDecimal paidAmount) {
if (entity.getPreSettlementId() == null) return;
if (!List.of("progress_advance", "settlement_payment").contains(entity.getPaymentType())) return;
// 多选预结算单时单据上仅存第一张预结算,全部选中单据在关联表:按关联行逐单回填预付信息,
// 已付金额按关联行申请金额分摊(与 syncFormalSettlementPayments 同口径);单选无关联行,回退单据上的预结算与申请全额
List<PaymentApplicationSettlement> relations = settlementRelationMapper.selectList(
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
.eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId())
.isNotNull(PaymentApplicationSettlement::getPreSettlementId)
.eq(PaymentApplicationSettlement::getIsDeleted, 0));
if (relations.isEmpty()) {
PaymentApplicationSettlement single = new PaymentApplicationSettlement();
single.setPreSettlementId(entity.getPreSettlementId());
single.setAppliedAmount(entity.getAppliedAmount());
relations = List.of(single);
}
BigDecimal remainingPaid = money(paidAmount);
for (int index = 0; index < relations.size(); index++) {
PaymentApplicationSettlement relation = relations.get(index);
BigDecimal paid = index == relations.size() - 1 ? remainingPaid
: money(relation.getAppliedAmount()).min(remainingPaid);
remainingPaid = remainingPaid.subtract(paid);
relation.setPaidAmount(paid);
syncOnePreSettlementAdvance(entity, relation, billStatus);
}
}
/**
* 按预结算单逐单建档/更新预付记录:金蝶付款申请单生成成功(synced)即回显预付信息,付款完成回写(paid)更新为已付款
*
* @param relation 预结算关联行,携带预结算单id、申请金额与本次分摊的已付金额
*/
private void syncOnePreSettlementAdvance(PaymentApplication entity, PaymentApplicationSettlement relation, String billStatus) {
Long preSettlementId = relation.getPreSettlementId();
PreSettlementAdvance advance = preSettlementAdvanceMapper.selectList(
Wrappers.<PreSettlementAdvance>lambdaQuery()
.eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId())
.eq(PreSettlementAdvance::getPreSettlementId, preSettlementId)
.eq(PreSettlementAdvance::getIsDeleted, 0)
.ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)
.eq(PreSettlementAdvance::getAdvanceNo, entity.getPaymentNo())
.last("limit 1")).stream().findFirst().orElse(null);
if (advance == null) {
advance = preSettlementAdvanceMapper.selectList(Wrappers.<PreSettlementAdvance>lambdaQuery()
.eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId())
.eq(PreSettlementAdvance::getPreSettlementId, preSettlementId)
.eq(PreSettlementAdvance::getIsDeleted, 0)
.ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)
.eq(PreSettlementAdvance::getPaidAmount, BigDecimal.ZERO)
.eq(PreSettlementAdvance::getAppliedAmount, money(entity.getAppliedAmount()))
.eq(PreSettlementAdvance::getAppliedAmount, money(relation.getAppliedAmount()))
.orderByDesc(PreSettlementAdvance::getCreateTime).last("limit 1")).stream().findFirst().orElse(null);
}
if (advance == null) {
advance = new PreSettlementAdvance();
advance.setPreSettlementId(entity.getPreSettlementId());
advance.setPreSettlementId(preSettlementId);
advance.setAdvanceNo(entity.getPaymentNo());
advance.setAppliedAmount(money(entity.getAppliedAmount()));
advance.setAppliedAmount(money(relation.getAppliedAmount()));
}
advance.setPaidAmount(paidAmount);
advance.setPaidAmount(money(relation.getPaidAmount()));
advance.setBillStatus(billStatus);
advance.setKingdeeAdvanceNo(entity.getKingdeeBillNo());
if (advance.getId() == null) preSettlementAdvanceMapper.insert(advance);
else preSettlementAdvanceMapper.updateById(advance);
refreshPreSettlementAdvanceSummary(entity.getPreSettlementId());
refreshPreSettlementAdvanceSummary(preSettlementId);
formalSettlementSourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
.eq(FormalSettlementSource::getPreSettlementId, entity.getPreSettlementId())
.eq(FormalSettlementSource::getPreSettlementId, preSettlementId)
.eq(FormalSettlementSource::getIsDeleted, 0)).forEach(source -> {
source.setAdvanceAppliedAmount(findPreSettlementApplied(entity.getPreSettlementId()));
source.setAdvancePaidAmount(findPreSettlementPaid(entity.getPreSettlementId()));
source.setAdvanceAppliedAmount(findPreSettlementApplied(preSettlementId));
source.setAdvancePaidAmount(findPreSettlementPaid(preSettlementId));
formalSettlementSourceMapper.updateById(source);
});
formalSettlementService.refreshPaymentSummariesForPreSettlement(entity.getPreSettlementId());
formalSettlementService.refreshPaymentSummariesForPreSettlement(preSettlementId);
}
/**
@@ -1681,13 +1722,21 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private void voidPreSettlementAdvance(PaymentApplication entity) {
if (entity.getPreSettlementId() == null) return;
if (!List.of("progress_advance", "settlement_payment").contains(entity.getPaymentType())) return;
// 多选预结算单的预付记录按关联行逐单作废;单选回退单据上的预结算
List<Long> preSettlementIds = new ArrayList<>(List.of(entity.getPreSettlementId()));
settlementRelationMapper.selectList(Wrappers.<PaymentApplicationSettlement>lambdaQuery()
.eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId())
.isNotNull(PaymentApplicationSettlement::getPreSettlementId)
.eq(PaymentApplicationSettlement::getIsDeleted, 0))
.stream().map(PaymentApplicationSettlement::getPreSettlementId).filter(Objects::nonNull).distinct()
.forEach(preSettlementIds::add);
preSettlementAdvanceMapper.update(null, Wrappers.<PreSettlementAdvance>lambdaUpdate()
.eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId())
.in(PreSettlementAdvance::getPreSettlementId, preSettlementIds)
.eq(PreSettlementAdvance::getAdvanceNo, entity.getPaymentNo())
.eq(PreSettlementAdvance::getIsDeleted, 0)
.ne(PreSettlementAdvance::getBillStatus, "paid")
.set(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED));
refreshPreSettlementAdvanceSummary(entity.getPreSettlementId());
preSettlementIds.forEach(this::refreshPreSettlementAdvanceSummary);
}
private BigDecimal findPreSettlementPaid(Long preSettlementId) {
@@ -463,6 +463,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
settlement.setCurrentProcessor(AuthUtil.getUserName());
settlement.setVoidReason(reason);
updateById(settlement);
// 联动删除项目预付核销行,避免残留记录与可核销占用
this.projectAdvanceWriteOffService.deleteBySettlementIds(
TransportBusiConstant.SETTLEMENT_BILL_PRE, List.of(settlement.getId()));
releaseSourceDetails(settlement, listDetails(settlement.getId()));
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "作废预结算单", reason);
}
@@ -4,12 +4,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.event.KingdeeBillApprovedEvent;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper;
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
import org.springblade.transport.mapper.FormalSettlementChangeRecordMapper;
@@ -33,7 +39,10 @@ import org.springblade.transport.pojo.entity.PaymentApplicationSettlement;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
import org.springblade.transport.pojo.entity.SettlementAdjustmentDetail;
import org.springblade.transport.pojo.vo.SettlementAdjustmentVO;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.ISettlementAdjustmentService;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -51,6 +60,7 @@ import java.util.Set;
@Service
@RequiredArgsConstructor
@Slf4j
public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementAdjustmentMapper, SettlementAdjustment>
implements ISettlementAdjustmentService {
private static final String PAID = "paid";
@@ -63,6 +73,11 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
private final FormalSettlementPaymentMapper formalPaymentMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
private final PaymentApplicationSettlementMapper paymentApplicationSettlementMapper;
private final IFormalSettlementService formalSettlementService;
private final ISettlementProjectAdvanceWriteOffService projectAdvanceWriteOffService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
private final ApplicationEventPublisher eventPublisher;
@Override
public IPage<SettlementAdjustmentVO> selectPage(IPage<SettlementAdjustment> page, SettlementAdjustmentVO query) {
@@ -204,24 +219,142 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
}
@Override public void returnBill(SettlementAdjustmentStatusRequest request) { changeStatus(request.getId(), TransportBusiConstant.REVIEWING, TransportBusiConstant.RETURNED, "已驳回", limit(request.getReason(), 200)); }
/**
* 重新推送(高危):原单已推送金蝶且旧单已在金蝶冲销后,克隆新正式结算单并应用调整,
* 以新单号组成新防重键生成全新应付/应收单,调整单改挂新单、原单标记已调整并解锁旧锁。
* <p>
* 非事务编排:克隆+应用(独立事务)→ 推送(外部远程)→ 收尾回填,推送失败不回滚克隆,
* 重试时按来源原单号幂等复用已克隆新单,从推送步骤续上。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public String repush(Long adjustmentId) {
SettlementAdjustment adjustment = existing(adjustmentId);
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批通过的结算调整单允许重新推送");
FormalSettlement formal = formalMapper.selectById(adjustment.getFormalSettlementId());
if (formal == null || !"synced".equals(formal.getKingdeeSyncStatus())) throw new ServiceException("关联正式结算单尚未推送金蝶,无需重新推送");
String kingdeeNo = "K3AP" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
formal.setKingdeeBillNo(kingdeeNo); formal.setSyncedTime(LocalDateTime.now()); formalMapper.updateById(formal);
return kingdeeNo;
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
throw new ServiceException("仅审批通过的结算调整单允许重新推送");
}
FormalSettlement original = formalMapper.selectById(adjustment.getFormalSettlementId());
if (original == null) {
throw new ServiceException("关联正式结算单不存在");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(original.getKingdeeSyncStatus())) {
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
+ "]尚未推送金蝶,请先执行「推送金蝶」");
}
// 冲销硬闸门:仅当金蝶侧原应付/应收单已被冲销(部分冲销放行)才允许以新单号重推,避免重复生成财务单据
if (!this.kingdeeWrittenOff(original)) {
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
+ "]的原应付/应收单尚未在金蝶冲销,禁止重推");
}
// 幂等:同一原单已克隆过新单则复用(重试从推送步骤续上),避免重复建单
FormalSettlement clone = existingClone(original.getFormalSettlementNo());
if (clone == null) {
Long cloneId = self().cloneAndApplyForRepush(original.getId(), adjustment.getId());
clone = formalMapper.selectById(cloneId);
}
// 推送新单:新单号天然组成新防重键,内部完成 A102-2/A302-2 推送 + 落锁 + 附件上传
// shortcut: 成功后若再次点击重推,调整单已改挂新单且新单未冲销,会被冲销闸门拦截(无副作用);如需彻底隐藏由前端按「关联单是否克隆单」判断
String billNo = formalSettlementService.syncKingdee(clone.getId());
// 成功收尾:调整单改挂新单 + 回填金蝶单号/同步状态;原单标记已调整 + 旧锁解锁(业务数据不动)
SettlementAdjustment patch = new SettlementAdjustment();
patch.setId(adjustment.getId());
patch.setFormalSettlementId(clone.getId());
patch.setFormalSettlementNo(clone.getFormalSettlementNo());
patch.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
patch.setKingdeeBillNo(billNo);
updateById(patch);
formalSettlementService.markSettlementAdjusted(original.getId(), adjustment.getAdjustmentNo(), clone.getFormalSettlementNo());
return billNo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void approve(SettlementAdjustmentStatusRequest request) {
public Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId) {
IFormalSettlementService.CloneResult cloneResult = formalSettlementService.cloneFormalSettlement(originalSettlementId);
FormalSettlement clone = formalMapper.selectById(cloneResult.settlementId());
applyAdjustmentToSettlement(clone, existing(adjustmentId), cloneResult.detailIdMap(), cloneResult.feeIdMap());
return cloneResult.settlementId();
}
@Override
public String syncKingdee(Long adjustmentId) {
SettlementAdjustment adjustment = existing(adjustmentId);
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
throw new ServiceException("仅审批通过的结算调整单允许推送金蝶");
}
// 未推送/生成失败场景:推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态
return formalSettlementService.syncKingdee(adjustment.getFormalSettlementId());
}
@Override
public Map<String, Object> kingdeeWriteOffPrecheck(Long formalSettlementId) {
Map<String, Object> result = new HashMap<>();
FormalSettlement settlement = formalMapper.selectById(formalSettlementId);
if (settlement == null) {
throw new ServiceException("关联正式结算单不存在");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(settlement.getKingdeeSyncStatus())) {
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED);
result.put("writtenOff", null);
result.put("message", "该结算单尚未推送金蝶,审批通过后将自动推送原单");
return result;
}
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
try {
boolean writtenOff = this.kingdeeWrittenOff(settlement);
result.put("writtenOff", writtenOff);
result.put("message", writtenOff
? "该结算单已推送金蝶且旧单已冲销,审批后可直接重新推送新单"
: "该结算单已推送金蝶且旧单尚未冲销,审批通过后需先由财务在金蝶完成冲销,才能重新推送");
} catch (Exception exception) {
// 预检为软提示:金蝶查询异常降级为「未校验」,不阻断建单,由审批分流与重推闸门兜底
log.warn("结算调整单建单预检:金蝶冲销状态查询失败,降级未校验,正式结算单[{}]",
settlement.getFormalSettlementNo(), exception);
result.put("writtenOff", null);
result.put("message", "金蝶冲销状态查询失败,未校验;审批后请以实际冲销情况为准");
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String approve(SettlementAdjustmentStatusRequest request) {
SettlementAdjustment adjustment = existing(request.getId());
if (!TransportBusiConstant.REVIEWING.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批中的结算调整单允许审核");
FormalSettlement formal = adjustableFormalSettlement(adjustment.getFormalSettlementId());
boolean synced = KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(formal.getKingdeeSyncStatus());
if (synced) {
// 已推送场景:原单业务数据零改动,仅追加 1 条重推提示变更记录,待财务在金蝶手工冲销旧单后重推
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"结算调整单" + adjustment.getAdjustmentNo() + "审批通过,调整金额【" + money(adjustment.getAdjustmentAmount()) + "】,待重推金蝶",
adjustment.getRemark());
} else {
// 未推送场景:回写原单费用/金额并重算结算金额,事务提交后由事件监听自动推送金蝶
applyAdjustmentToSettlement(formal, adjustment, Map.of(), Map.of());
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
adjustment.getRemark());
}
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
if (!synced) {
// 审批事务提交后(AFTER_COMMIT)自动推原单,成功回填调整单 synced + 金蝶单号、失败置 failed
eventPublisher.publishEvent(new KingdeeBillApprovedEvent(
KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT, formal.getId()));
return null;
}
return "原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理后再重新推送";
}
/**
* 应用调整到目标正式结算单(未推送场景作用于原单,重推场景作用于克隆新单):
* 回写明细费项金额、汇总费用项、重算明细与结算金额;明细/费项 id 经映射转换(原单调整作用于原单时传空映射)。
*
* @param formal 目标正式结算单(改动后需落库)
* @param adjustment 结算调整单
* @param detailIdMap 原结算明细id -&gt; 目标结算明细id(原单场景传空 Map)
* @param feeIdMap 原明细费项id -&gt; 目标明细费项id(原单场景传空 Map)
*/
private void applyAdjustmentToSettlement(FormalSettlement formal, SettlementAdjustment adjustment,
Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
for (SettlementAdjustmentDetail item : detailMapper.selectList(Wrappers.<SettlementAdjustmentDetail>lambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) {
if (money(item.getAdjustmentAmountTax()).signum() == 0) continue;
if (isManualFee(item)) {
@@ -229,12 +362,14 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
"新增【" + item.getFeeItem() + "】调整费用【" + money(item.getAdjustmentAmountTax()) + "】",
Func.isEmpty(item.getRemark()) ? adjustment.getRemark() : item.getRemark());
} else {
FormalSettlementDetailFee fee = validateFee(formal.getId(), item.getFormalSettlementDetailId(), item.getFormalSettlementDetailFeeId());
Long detailId = detailIdMap.getOrDefault(item.getFormalSettlementDetailId(), item.getFormalSettlementDetailId());
Long feeId = feeIdMap.getOrDefault(item.getFormalSettlementDetailFeeId(), item.getFormalSettlementDetailFeeId());
FormalSettlementDetailFee fee = validateFee(formal.getId(), detailId, feeId);
BigDecimal beforeAmount = money(fee.getSettlementAmountTax());
fee.setSettlementAmountTax(beforeAmount.add(money(item.getAdjustmentAmountTax())));
if (item.getAdjustmentAmountNoTax() != null) fee.setSettlementAmountNoTax(money(fee.getSettlementAmountNoTax()).add(item.getAdjustmentAmountNoTax()));
fee.setAdjustAmount(money(fee.getAdjustAmount()).add(item.getAdjustmentAmountTax())); formalFeeMapper.updateById(fee);
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(item.getFormalSettlementDetailId());
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(detailId);
saveFormalChange(formal.getId(), "结算明细项", formalDetail == null ? null : formalDetail.getLineNo(),
"调整", "【" + (Func.isEmpty(item.getFeeItem()) ? "结算费用" : item.getFeeItem())
+ "】从【" + beforeAmount + "】调整为【" + fee.getSettlementAmountTax() + "】",
@@ -249,15 +384,46 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
detail.setAdjustAmount(detail.getSettlementAmountTax().subtract(money(detail.getOriginalAmount()))); formalDetailMapper.updateById(detail);
}
BigDecimal amount = money(formal.getSettlementAmount()).add(money(adjustment.getAdjustmentAmount()));
// 调整后结算金额不得低于项目预付核销合计,否则已付将超过结算金额且无法拦截
BigDecimal writeOffTotal = this.projectAdvanceWriteOffService.sumBySettlement(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, formal.getId());
if (money(writeOffTotal).compareTo(money(amount)) > 0) {
throw new ServiceException("调整后结算金额不能低于项目预付核销合计" + money(writeOffTotal) + ",请先调减项目预付核销");
}
formal.setSettlementAmount(amount);
formal.setLocalSettlementAmount(amount.multiply(formal.getExchangeRate() == null ? BigDecimal.ONE : formal.getExchangeRate()));
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount()))
.max(BigDecimal.ZERO));
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount())).max(BigDecimal.ZERO));
formalMapper.updateById(formal);
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
adjustment.getRemark());
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
// 重算收付汇总(含核销计入),保持付款状态与剩余可付准确
formalSettlementService.refreshPaymentSummary(formal.getId());
}
/** 涉事事务的自代理:跨方法调用确保 {@link #cloneAndApplyForRepush} 的事务注解生效 */
private ISettlementAdjustmentService self() {
return SpringUtil.getBean(ISettlementAdjustmentService.class);
}
/** 按来源原正式结算单号查已克隆新单(重推幂等:同一原单不重复建单) */
private FormalSettlement existingClone(String originFormalSettlementNo) {
List<FormalSettlement> clones = formalMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getOriginFormalSettlementNo, originFormalSettlementNo)
.orderByDesc(FormalSettlement::getCreateTime));
return clones.isEmpty() ? null : clones.get(0);
}
/** 按原单旧防重键(原结算单号_明细单据号)查金蝶是否已冲销(应付 A102-4 / 应收 A302-4,部分冲销放行) */
private boolean kingdeeWrittenOff(FormalSettlement settlement) {
List<FormalSettlementDetail> details = formalDetailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId())
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
if (Func.isEmpty(details)) {
return false;
}
if ("receivable".equals(settlement.getSettlementType())) {
return kingdeeArBillSyncSupport.queryArBillWrittenOff(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details));
}
return kingdeeApBillSyncSupport.queryApBillWrittenOff(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details));
}
private void saveFormalChange(Long settlementId, String changeType, Integer lineNo,
@@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper;
@@ -28,8 +29,11 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -56,6 +60,7 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP
.eq(PaymentApplication::getContractId, contractId)
.eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE)
.eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
.eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID)
.eq(PaymentApplication::getIsDeleted, 0)
.orderByDesc(PaymentApplication::getCreateTime));
List<ProjectAdvanceWriteOffOptionVO> options = new ArrayList<>();
@@ -121,6 +126,20 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP
return money(writeOffMapper.sumWriteOffBySettlement(settlementType, settlementId));
}
@Override
public Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds) {
if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) {
return Map.of();
}
Map<Long, BigDecimal> result = new HashMap<>();
for (Map<String, Object> row : writeOffMapper.sumWriteOffBySettlementIds(settlementType, settlementIds)) {
if (row.get("settlementId") instanceof Number settlementId && row.get("totalAmount") instanceof BigDecimal totalAmount) {
result.put(settlementId.longValue(), money(totalAmount));
}
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void replaceWriteOffs(String settlementType, Long settlementId, Long contractId,
@@ -157,6 +176,9 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP
if (!TransportBusiConstant.APPROVED.equals(application.getApprovalStatus())) {
throw new ServiceException("仅可选择审核通过的项目预付单");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(application.getKingdeeStatus())) {
throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "") + "尚未付款,项目预付须金蝶付款完成后方可核销");
}
if (!Objects.equals(contractId, application.getContractId())) {
throw new ServiceException("项目预付单与结算单合同不一致");
}
@@ -203,6 +225,40 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId));
}
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteBySettlementIds(String settlementType, Collection<Long> settlementIds) {
if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) {
return 0;
}
return writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
.in(SettlementProjectAdvanceWriteOff::getSettlementId, settlementIds));
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<Long> deleteByPaymentApplicationIds(Collection<Long> paymentApplicationIds) {
if (Func.isEmpty(paymentApplicationIds)) {
return List.of();
}
List<SettlementProjectAdvanceWriteOff> rows = writeOffMapper.selectList(
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)
.in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds));
if (rows.isEmpty()) {
return List.of();
}
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds));
return rows.stream()
.filter(row -> TransportBusiConstant.SETTLEMENT_BILL_FORMAL.equals(row.getSettlementType()))
.map(SettlementProjectAdvanceWriteOff::getSettlementId)
.filter(Objects::nonNull)
.distinct()
.toList();
}
private BigDecimal money(BigDecimal value) {
return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
: value.setScale(2, RoundingMode.HALF_UP);
@@ -200,6 +200,33 @@ public class KingdeeApBillSyncSupport {
return null;
}
/**
* A102-4 冲销状态查询:按防重键查询财务应付单,返回该防重键下是否存在「已被冲销」单据
* (BfiApBillVO.hadwrittenoff=true,部分冲销同样视为已冲销)。
* 结算调整重推硬闸门用:仅当原单已被冲销才允许以新来源单据号重推新应付单
*
* @param awrpSrcCode 财务应付单防重键
* @return true=金蝶侧原应付单已被冲销
*/
public boolean queryApBillWrittenOff(String awrpSrcCode) {
if (Func.isBlank(awrpSrcCode)) {
return false;
}
BfiApBillQueryDTO param = new BfiApBillQueryDTO();
param.setAwrpSrcCode(List.of(awrpSrcCode));
param.setAwrpSrcSys(bfiProperties.getSrcSys());
for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(PAY_RESULT_PAGE_SIZE);
return bfiService.queryApBill(param);
})) {
if (awrpSrcCode.equals(apBill.getAwrpSrcCode()) && Boolean.TRUE.equals(apBill.getHadwrittenoff())) {
return true;
}
}
return false;
}
// ==================== A102-2 财务应付单保存 ====================
/**
@@ -860,7 +887,7 @@ public class KingdeeApBillSyncSupport {
BfiBillSaveResultItemVO item = saveResult.getResult().get(0);
if (!Boolean.TRUE.equals(item.getBillStatus())) {
String errorText = Func.isBlank(item.getErrorText()) ? "未知错误" : item.getErrorText();
throw new ServiceException(action + "失败:" + errorText);
throw new ServiceException(action + "失败:" + errorText + item.getFailMark());
}
}
@@ -947,16 +974,7 @@ public class KingdeeApBillSyncSupport {
* @return 组织编码
*/
public String kingdeeOrgNumber(Long projectId, KingdeeSyncContext context) {
if (projectId == null) {
throw new ServiceException("推送金蝶失败,单据未关联项目,无法确定金蝶组织编码");
}
ProjectApply project = context.projectCache().computeIfAbsent(projectId, projectApplyMapper::selectById);
if (project == null) {
throw new ServiceException("推送金蝶失败,项目[" + projectId + "]不存在,无法确定金蝶组织编码");
}
if (project.getBusinessDeptId() == null) {
throw new ServiceException("推送金蝶失败,项目[" + Func.toStr(project.getProjectName()) + "]未维护业务部门,无法确定金蝶组织编码");
}
ProjectApply project = this.requireOrgProject(projectId, context);
// 沿部门树向上反查平台公司,上限 20 级防脏数据成环死循环
Dept dept = SysCache.getDept(project.getBusinessDeptId());
for (int depth = 0; dept != null && depth < 20; depth++) {
@@ -971,6 +989,38 @@ public class KingdeeApBillSyncSupport {
throw new ServiceException("推送金蝶失败,项目业务部门[" + Func.toStr(project.getBusinessDeptName()) + "]未归属平台公司,无法确定金蝶组织编码");
}
/**
* 金蝶部门编码:项目业务部门自身的金蝶编码(blade_dept.kingdee_dept_code,不上溯平台公司),
* 用于付款申请单 awrp_department_number(部门.编码);项目未关联/未维护业务部门/业务部门未维护金蝶编码时拦截
*
* @param projectId 项目id
* @param context 金蝶同步上下文(缓存载体)
* @return 部门编码
*/
public String kingdeeDeptNumber(Long projectId, KingdeeSyncContext context) {
ProjectApply project = this.requireOrgProject(projectId, context);
Dept dept = SysCache.getDept(project.getBusinessDeptId());
if (dept == null || Func.isBlank(dept.getKingdeeDeptCode())) {
throw new ServiceException("推送金蝶失败,项目业务部门[" + Func.toStr(project.getBusinessDeptName()) + "]未维护金蝶编码,请检查部门数据");
}
return dept.getKingdeeDeptCode();
}
/** 金蝶组织/部门编码共用校验:项目存在且已维护业务部门 */
private ProjectApply requireOrgProject(Long projectId, KingdeeSyncContext context) {
if (projectId == null) {
throw new ServiceException("推送金蝶失败,单据未关联项目,无法确定金蝶组织编码");
}
ProjectApply project = context.projectCache().computeIfAbsent(projectId, projectApplyMapper::selectById);
if (project == null) {
throw new ServiceException("推送金蝶失败,项目[" + projectId + "]不存在,无法确定金蝶组织编码");
}
if (project.getBusinessDeptId() == null) {
throw new ServiceException("推送金蝶失败,项目[" + Func.toStr(project.getProjectName()) + "]未维护业务部门,无法确定金蝶组织编码");
}
return project;
}
/**
* 合同结算币种转金蝶币别代码(RMB 统一映射 CNY)
*
@@ -138,6 +138,33 @@ public class KingdeeArBillSyncSupport {
return null;
}
/**
* A302-4 冲销状态查询:按防重键查询财务应收单,返回该防重键下是否存在「已被冲销」单据
* (BfiArBillVO.hadwrittenoff=true,部分冲销同样视为已冲销)。
* 结算调整重推硬闸门用:仅当原单已被冲销才允许以新来源单据号重推新应收单
*
* @param arBillSrcCode 财务应收单防重键
* @return true=金蝶侧原应收单已被冲销
*/
public boolean queryArBillWrittenOff(String arBillSrcCode) {
if (Func.isBlank(arBillSrcCode)) {
return false;
}
BfiArBillQueryDTO param = new BfiArBillQueryDTO();
param.setAwrpSrcCode(List.of(arBillSrcCode));
param.setAwrpSrcSys(bfiProperties.getSrcSys());
for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(AR_RESULT_PAGE_SIZE);
return bfiService.queryArBill(param);
})) {
if (arBillSrcCode.equals(arBill.getAwrpSrcCode()) && Boolean.TRUE.equals(arBill.getHadwrittenoff())) {
return true;
}
}
return false;
}
// ==================== A302-2 财务应收单保存 ====================
/**
@@ -45,6 +45,7 @@ public class FormalSettlementWrapper extends BaseEntityWrapper<FormalSettlement,
case "voided" -> "已作废";
default -> entity.getApprovalStatus();
});
vo.setAdjustStatusName("adjusted".equals(entity.getAdjustStatus()) ? "已调整" : null);
return vo;
}
}