diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java index aa4daff4..ec68737b 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java @@ -17,7 +17,40 @@ public interface ISettlementAdjustmentService { Long saveDraft(SettlementAdjustmentSaveRequest request); void removeDraft(Long id); void submit(SettlementAdjustmentStatusRequest request); - void approve(SettlementAdjustmentStatusRequest request); + /** + * 审批通过:未推送场景回写原单并发布金蝶自动推送事件;已推送场景原单零改动仅追加变更记录。 + * + * @param request 审批请求 + * @return 已推送场景的手工冲销提示文案,未推送场景返回 null + */ + String approve(SettlementAdjustmentStatusRequest request); void returnBill(SettlementAdjustmentStatusRequest request); String repush(Long adjustmentId); + + /** + * 选单后金蝶冲销预检(软提示,不阻断建单):未推送 / 已推送已冲销 / 已推送未冲销; + * 金蝶查询异常降级返回「未校验」,由审批分流与重推闸门兜底 + * + * @param formalSettlementId 选定正式结算单id + * @return syncStatus / writtenOff / message + */ + Map kingdeeWriteOffPrecheck(Long formalSettlementId); + + /** + * 推送金蝶(未推送/生成失败场景):推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态 + * + * @param adjustmentId 结算调整单id + * @return 金蝶单据编号 + */ + String syncKingdee(Long adjustmentId); + + /** + * 重推克隆+应用(独立事务,由 repush 经自代理调用):克隆原单为新正式结算单并应用调整, + * 供推送失败重试时幂等复用 + * + * @param originalSettlementId 原正式结算单id + * @param adjustmentId 结算调整单id + * @return 新正式结算单id + */ + Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeLockConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeLockConstant.java index 6737bc3e..5b259f10 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeLockConstant.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeLockConstant.java @@ -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"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementAdjustmentController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementAdjustmentController.java index a8469417..d03d8d1d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementAdjustmentController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementAdjustmentController.java @@ -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 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 repush(@RequestParam Long id) { return R.data(service.repush(id)); } + @GetMapping("/kingdee-writeoff-precheck") public R> kingdeeWriteOffPrecheck(@RequestParam Long formalSettlementId) { return R.data(service.kingdeeWriteOffPrecheck(formalSettlementId)); } + @PostMapping("/sync-kingdee") public R syncKingdee(@RequestParam Long id) { return R.data(service.syncKingdee(id)); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java index 6438f026..755e8c45 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java @@ -60,6 +60,17 @@ public interface IKingdeeBillLockService extends IService { */ 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 为终态不返回(前端视为无锁/已解锁) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java index c39e563c..02aefab7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java @@ -251,6 +251,28 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl lockedRecords = this.list(Wrappers.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.lambdaQuery() @@ -309,8 +331,11 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl "金蝶删单"; + case KingdeeLockConstant.OPERATION_ADJUST_REPUSH -> "结算调整重推"; + default -> "金蝶锁单"; + }); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java index 8dda1c1c..379d11b0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java @@ -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,9 @@ 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.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,6 +59,7 @@ import java.util.Set; @Service @RequiredArgsConstructor +@Slf4j public class SettlementAdjustmentServiceImpl extends BaseServiceImpl implements ISettlementAdjustmentService { private static final String PAID = "paid"; @@ -63,6 +72,10 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl selectPage(IPage page, SettlementAdjustmentVO query) { @@ -204,24 +217,140 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl + * 非事务编排:克隆+应用(独立事务)→ 推送(外部远程)→ 收尾回填,推送失败不回滚克隆, + * 重试时按来源原单号幂等复用已克隆新单,从推送步骤续上。 + */ @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("关联正式结算单尚未推送金蝶,请先执行「推送金蝶」"); + } + // 冲销硬闸门:仅当金蝶侧原应付/应收单已被冲销(部分冲销放行)才允许以新单号重推,避免重复生成财务单据 + if (!this.kingdeeWrittenOff(original)) { + throw new ServiceException("原应付/应收单尚未在金蝶冲销,禁止重推"); + } + // 幂等:同一原单已克隆过新单则复用(重试从推送步骤续上),避免重复建单 + 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 kingdeeWriteOffPrecheck(Long formalSettlementId) { + Map 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 -> 目标结算明细id(原单场景传空 Map) + * @param feeIdMap 原明细费项id -> 目标明细费项id(原单场景传空 Map) + */ + private void applyAdjustmentToSettlement(FormalSettlement formal, SettlementAdjustment adjustment, + Map detailIdMap, Map feeIdMap) { for (SettlementAdjustmentDetail item : detailMapper.selectList(Wrappers.lambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) { if (money(item.getAdjustmentAmountTax()).signum() == 0) continue; if (isManualFee(item)) { @@ -229,12 +358,14 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl clones = formalMapper.selectList(Wrappers.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 details = formalDetailMapper.selectList(Wrappers.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,