!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
@@ -172,4 +172,12 @@ public class FormalSettlement extends TenantEntity {
* 作废原因,驳回时记录驳回原因
*/
private String voidReason;
/**
* 来源原正式结算单号:结算调整重推生成的新单,指向被调整的原单
*/
private String originFormalSettlementNo;
/**
* 调整状态:adjusted-已被结算调整(原单标记)
*/
private String adjustStatus;
}
@@ -30,6 +30,10 @@ public class SettlementAdjustment extends TenantEntity {
private String currentNode;
private String currentProcessor;
private String kingdeeSyncStatus;
/**
* 金蝶单据号(未推送场景回填原结算单金蝶单号;重推场景回填新单金蝶单号)
*/
private String kingdeeBillNo;
private String attachmentsJson;
private String remark;
private LocalDateTime approvedTime;
@@ -58,6 +58,8 @@ public class FormalSettlementVO extends FormalSettlement implements KingdeeUnloc
private String kingdeeUnlockOperator;
@TableField(exist = false) @Schema(description = "解锁动作名称:金蝶删单/金蝶锁单")
private String kingdeeUnlockOperationName;
@TableField(exist = false) @Schema(description = "调整状态名称:adjusted-已调整")
private String adjustStatusName;
@TableField(exist = false) private String preSettlementNos;
@TableField(exist = false) private String preSettlementNo;
@TableField(exist = false) private List<FormalSettlementSource> sources;
@@ -70,6 +72,7 @@ public class FormalSettlementVO extends FormalSettlement implements KingdeeUnloc
@TableField(exist = false) @Schema(description = "开票申请经金蝶回写的发票结果(含文件地址,只读展示)")
private List<InvoiceApplicationInvoice> issuedInvoices;
@TableField(exist = false) private List<PaymentApplicationVO> paymentApplications;
@TableField(exist = false) private List<SettlementAdvancePaymentVO> advancePayments;
@TableField(exist = false) private List<SettlementAdjustmentVO> adjustments;
@TableField(exist = false) private List<FormalSettlementChangeRecord> changeRecords;
}
@@ -17,7 +17,6 @@ public class SettlementAdjustmentVO extends SettlementAdjustment {
private String approvalStatusName;
private String settlementTypeName;
private String createUserName;
private String kingdeeBillNo;
private List<SettlementAdjustmentDetail> details;
private List<FormalSettlementDetail> formalDetails;
}
@@ -0,0 +1,72 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 结算单统一预付付款记录(进度预付 / 项目预付核销,供付款记录合并展示)
*
* @author Chill
*/
@Data
@Schema(description = "结算单统一预付付款记录")
public class SettlementAdvancePaymentVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "记录类型:progress_advance 进度预付 / project_advance 项目预付核销")
private String recordType;
@Schema(description = "付款单号")
private String paymentNo;
@Schema(description = "申请金额(进度预付为申请预付金额,项目预付为本次核销金额)")
private BigDecimal appliedAmount;
@Schema(description = "已付款金额(底层付款申请实付)")
private BigDecimal paidAmount;
@Schema(description = "单据状态")
private String billStatus;
@Schema(description = "金蝶单据号")
private String kingdeeBillNo;
@Schema(description = "创建人姓名")
private String createUserName;
@Schema(description = "创建时间(统一取底层付款申请创建时间)")
private Date createTime;
}
@@ -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<String, Object> 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);
}
@@ -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;
}
}
@@ -25,6 +25,7 @@
*/
package org.springblade.thirdparty.bfi.pojo.vo;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
@@ -91,4 +92,16 @@ public class BfiBillSaveResultItemVO implements Serializable {
.filter(text -> !text.isEmpty())
.collect(Collectors.joining(";"));
}
/**
* 失败定位信息:金蝶单据号,供异常提示展示,
* 「已提交状态下的单据不允许修改」等场景可凭金蝶单据号直接定位星瀚侧单据
*
* @return 定位信息文本,形如(金蝶单据:xxx),无可用信息返回空串
*/
@JsonIgnore
public String getFailMark() {
String billNo = keys == null ? null : keys.get("billno");
return billNo == null || billNo.isBlank() ? "" : "(金蝶单据:" + billNo + ")";
}
}
@@ -94,6 +94,7 @@ public class BfiBlacklistVO implements Serializable {
/**
* 失信类别[A:禁止交易类, B:风险警示类]
*/
@JsonProperty("awrp_combofield")
private String awrpCombofield;
/**
* 失信类别标题
@@ -79,6 +79,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO;
@@ -112,6 +113,7 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* 业财融合(BFI)服务实现
@@ -655,7 +657,7 @@ public class BfiServiceImpl implements IBfiService {
String message = Optional.ofNullable(result).map(BfiResultVO::getMessage).filter(StringUtil::isNotBlank)
.orElseGet(() -> Optional.ofNullable(result).map(BfiResultVO::getErrorCode)
.map(errorCode -> "错误码 " + errorCode).orElse("响应为空或解析失败"));
throw new BfiException("调用BFI接口" + type + "异常 " + message);
throw new BfiException("调用BFI接口" + type + "异常 " + message + billSaveFailDetail(result));
} catch (BfiException e) {
log.error("调用BFI接口 {},BfiException异常", type, e);
throw e;
@@ -665,6 +667,26 @@ public class BfiServiceImpl implements IBfiService {
}
}
/**
* 单据保存失败定位后缀:取失败分录的金蝶单据号,非单据保存类调用返回空串
*
* @param result BFI 响应
* @param <T> 响应数据类型
* @return 定位后缀,形如 (金蝶单据:xxx)
*/
private <T> String billSaveFailDetail(BfiResultVO<T> result) {
if (result == null || !(result.getData() instanceof BfiBillSaveResultVO billSave)
|| billSave.getResult() == null || billSave.getResult().isEmpty()) {
return "";
}
String detail = billSave.getResult().stream()
.filter(item -> !Boolean.TRUE.equals(item.getBillStatus()))
.map(BfiBillSaveResultItemVO::getFailMark)
.filter(mark -> !mark.isEmpty())
.collect(Collectors.joining(";"));
return detail.isEmpty() ? "" : " " + detail;
}
/**
* 收票 rim/message 契约公共调用方法,成功错误码兼容 0 与 0000
*
@@ -131,7 +131,8 @@ TMS 付款申请审核通过
| --- | --- | --- | --- |
| awrp_src_code | 来源单据号(防重键) | `paymentNo` | 付款申请单号 |
| awrp_src_sys | 外部系统编号(防重键) | 配置 `payApply.srcSys` | 默认 `WLTMS` |
| settleorg_number / applyorg_number / payorg_number / purorg_number | 结算/申请/付款/采购组织编码 | 合同所属组织 | `organizationId` → 组织编码 `deptCode`,为空回退组织名称;缺失报错 |
| settleorg_number / applyorg_number / payorg_number | 结算/申请/付款组织编码 | 合同关联项目业务部门上溯平台公司 | `kingdeeOrgNumber` → 平台公司 `blade_dept.kingdee_dept_code`;缺失报错 |
| awrp_department_number | 部门编码 | 项目业务部门自身 | `kingdeeDeptNumber` → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司);未维护报错 |
| billtype_number | 单据类型编码 | 配置 `payApply.billtype` | 默认 `ap_payapply_oth_BT_S` 其他付款申请 |
| applydate | 申请日期 | `applyDate` | yyyy-MM-dd,空取当天 |
| applycause | 请款事由 | paymentType 映射 | 见 5.1 |
@@ -158,7 +159,7 @@ TMS 付款申请审核通过
| awrp_entry_code | 对方唯一行 id(防重) | `paymentNo + "-1"` | 不得重复 |
| e_remark | 备注 | `remark` | 可选 |
未传的可选字段:purdept_number、awrp_department_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。
未传的可选字段:purorg_number、purdept_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。
> 前置校验:推送前按 `contractId` 加载合同,未关联合同、合同不存在、所属组织编码为空、结算币种为空时,同步失败并标记 `kingdeeStatus = failed`。
@@ -222,7 +223,7 @@ TMS 付款申请审核通过
| 场景 | 行为 |
| --- | --- |
| 单笔同步 `syncKingdee` | 校验失败/推送失败置 `failed` 并抛 `ServiceException`,前端提示原因(含金蝶 errors 明细) |
| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;失败单标记 `failed` 后继续下一单;返回结果列表中附 `付款申请[id]同步失败:原因` |
| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;失败单标记 `failed` 后继续下一单;返回结果列表中附 `付款申请[付款申请单号]同步失败:原因` |
| 金蝶接口级失败(status=false / errorCode≠0) | `BfiServiceImpl.commonCall` 抛 `BfiException`,同上处理 |
| 金蝶单据级失败(billStatus=false) | 以 `errors` 拼接抛出 |
| 重复点击同步 | 已 `synced` 单据跳过;`failed`/`unsynced` 允许重推(防重键保证金蝶侧幂等) |
@@ -2,7 +2,7 @@
> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport)
> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关)
> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径)
> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径)
> 最后更新:2026-09-18(字段映射与联调默认值按当前代码重新梳理)
---
@@ -15,7 +15,7 @@
- 对接接口:
- **A102-2 财务应付单保存** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save`
- **A103-1 付款申请保存(应付下推)** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/push`
- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《付款申请同步金蝶(星瀚)技术文档》
- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《2026-09-16-付款申请同步金蝶(星瀚)技术文档》
- 调用方式:前端 → TMS 后端转发 → 星瀚(OAuth2 凭证仅存后端,前端不持有)
- 落库结果:A103-1 返回的付款申请单 id 写入 `kingdee_bill_no`(既有字段);**A102-2 返回的应付单 id 不落库**,仅校验保存成功,后续如需可经 A102-4 按防重键(`awrp_src_sys` + `awrp_src_code`)查询(`BfiServiceImpl.queryApBill` 已封装),本次无表结构变更
@@ -216,7 +216,7 @@ A102-2 所需的金蝶侧固定档案编码**不进 Nacos 配置**,统一定
| --- | --- |
| 单笔同步 `syncKingdee` | 校验失败/推送失败置 `failed` 并抛 `ServiceException`,前端提示原因(含金蝶 errors 明细) |
| ①成功②失败 | `kingdeeStatus = failed`;重推时①按防重键走更新语义,继续② |
| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;返回结果列表附 `付款申请[id]同步失败:原因` |
| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;返回结果列表附 `付款申请[付款申请单号]同步失败:原因` |
| 货物类型未维护档案编码 / 计量单位为空 | 分录解析(`collectEntryGroups`)时拦截,报错提示补充档案 |
| 合同乙方为空 / 客商档案未维护客商编码 | 往来户取值(`bfiAsstactNumber`)时拦截,报错提示合同号与乙方名称 |
| 金蝶接口级失败 | `BfiServiceImpl.commonCall` 抛 `BfiException`,同上处理 |
@@ -2,7 +2,7 @@
> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport)
> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关)
> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md`
> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md`
> 最后更新:2026-09-17
---
@@ -4,7 +4,7 @@
> 关联文档:
> - `doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(图03 财务应付及付款,步骤4 推送金蝶 / 步骤13 异常回收)
> - `doc/adr/0005-B901事件驱动双表锁单模型.md`(UNLOCK 置失败态不自动重推;A103-2 连续两轮查询消失兜底复用 `unlockBill()`)
> - `doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义)
> - `doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义)
> - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md`
> - `doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md`
@@ -248,21 +248,62 @@
### 4.1 任务清单(Nacos `thirdparty.bfi.*` 配置)
| 任务 | 干什么 | 开关键 | cron 键(默认频率) | 手动替代按钮 |
| 任务 | 干什么 | 开关键 | cron 键(默认值) | 手动替代按钮 |
|---|---|---|---|---|
| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(10 分钟) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 |
| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | 每 10 分钟 | 收票「同步金蝶结果」 |
| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | 每天 02:00 | 客商「同步金蝶」 |
| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | 每 10 分钟 | 流水行内「推送金蝶」 |
| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | 每 30 分钟 | 手动端点 `POST /invoice-receipt/sync-invoice-pool` |
| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | 每 10 分钟 | 各页面「同步金蝶」 |
| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | 每 5 分钟 | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` |
| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(默认 `0 1/10 * * * ?`) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 |
| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | `…receivedInvoiceResult.cron`(默认 `0 8/10 * * * ?`) | 收票「同步金蝶结果」 |
| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | `…customer.cron`(默认 `0 0 2 * * ?`) | 客商「同步金蝶」 |
| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | `…receiptClaimPush.cron`(默认 `0 4/10 * * * ?`) | 流水行内「推送金蝶」 |
| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | `…invoiceReceipt.cron`(默认 `0 16/30 * * * ?`) | 手动端点 `POST /invoice-receipt/sync-invoice-pool` |
| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | `…autoSync.retryCron`(默认 `0 5/10 * * * ?`) | 各页面「同步金蝶」 |
| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | `…billLock.retryCron`(默认 `0 2/5 * * * ?`) | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` |
### 4.2 审批自动同步开关(按业务域独立)
### 4.2 各任务的运转逻辑
**共性规则(先读这个)**
- **终态不轮询**:付款 paid(已付款)/closed(已关闭)、开票 invoiced(已开票)、收票 assigned(已指定应付)、客商 exist(金蝶已审核)、认领流水 pushed(已推送金蝶)后自动退出扫描范围——任务只处理「还没走完」的单据,越跑越轻,不会重复处理。
- **定时与手动同一套逻辑**:每个任务与它的「手动替代按钮」调用同一个服务方法,效果完全一致;区别只是触发方式。
- **单条失败不拖批次**:一轮里某条单据失败只记录原因,同批其余照常处理;失败的下轮自动再试(兜底见 ⑥ failed 重推)。
- **默认执行时间错峰**(付款 :01、认领 :04、failed 重推 :05、收票 :08、锁补偿每 5 分钟的 :02、票池每 30 分钟的 :16),互不挤占;自行改 cron 时建议保留错峰。
**① 付款结果回写(10 分钟)**
扫「审批通过且已推送 / 付款中 / 推送失败」的付款申请 → 到金蝶查付款申请单和付款处理单 → 推进 synced(已推送受理)→ paying(金蝶付款中)→ **paid(已付款)/ closed(已关闭)**,paid 时回写付款记录/凭证,并联动刷新预结算垫资、结算付款记录(3.4 第 3 步的「正常等定时回写」就是它)。
- 自愈一(应收勾稽):已推送但失败、或缺金蝶单号的**应收结算单**,查金蝶有则自动补单号、纠回 synced——「实际推成功但本地显示失败」的场景自动痊愈;
- 自愈二(开票结果):已受理的开票申请自动落发票信息并推进 **invoiced(已开票,终态)**(3.5 第 3 步的定时回写就是它);
- 防呆(金蝶删单):synced(已推送受理)/paying(金蝶付款中)的单据**连续 2 轮(约 20 分钟)在金蝶查不到** → 判定金蝶侧已删,自动解锁置 failed(推送失败)并清空金蝶单号,等人工核实后重推;重推带幂等预检,金蝶单据其实还在时会自动回填,不会重复生成。
**② 收票结果回写(10 分钟)**
扫 pushed(金蝶收票单已生成)/ assigned(已指定应付)的收票登记,按近 **30 天**回看窗口查金蝶收票单,一轮处理三种情况:
- 票在金蝶被作废/红冲 → 置 failed(推送失败)并提示人工处理(**不要重推**,见 3.6);
- 卡在 pushed → 自动补发「指定应付」推进 **assigned(已指定应付,终态)**(3.6 说的「卡在 pushed 不用管」就是它);
- assigned 但缺金蝶收票单号 → 自动补登单号。
窗口内查不到的保持现状不动(不判删除),由人工重推兜底。
**③ 客商同步(每天 02:00)**
一次扫全部审批通过客商,干三件事:
- **未推送/推送失败** → 先复查金蝶:已有且已审核的直接置 **exist(金蝶已有该客商且审核通过,终态)**(不重复建档);金蝶侧审核中则置 failed(推送失败)提示稍后;确实没有才推送;
- **已推送(synced,已推送受理)** → 金蝶审核通过自动升级 **exist(金蝶已审核,终态)**(3.1 第 5 步的定时升级就是它);
- **金蝶侧停用(enable=0)** → TMS 档案联动停用;金蝶恢复可用**不会**自动启用(单向联动,需人工启用)。
**④ 收款认领推送(10 分钟)**
扫「认领完成(claimed)且未推送/推送失败」的收款流水 → 按认领人逐人拉最新认领公告**对账**(本地认领金额与公告未认领金额一致才推)→ 推送成功回写金蝶收款单号、流水置 **pushed(已推送金蝶)**(3.7 第 3 步的「自动」就是它)。失败的下一轮自动重试,防重码按分摊结算单生成、重试不变,不会在金蝶重复生成收款单。
**⑤ 全票池同步(30 分钟)**
按近 **30 天**增量窗口分页拉取金蝶全票池发票到本地镜像表,收票选票、票池查询全部走本地镜像——所以**刚在金蝶开的票要等同步后才能选到**;急用可让运维手动触发同步端点,或用票号精确搜索(会自动去金蝶现查,见 3.6)。
**⑥ failed 重推(10 分钟)**
只重推 **failed(推送失败)** 单据(结算 → 付款 → 开票 → 收票四个域依次处理);**unsynced(审批通过但从未推送)不会自动推**——历史存量单不会被上线后批量补推,仍走手动按钮。前置没就绪(如上游应付单还没生成)会再次 failed、下轮继续;前置就绪后自动续推成功。
⚠️ 重推**没有次数上限**:根因不修(联行号缺失、组织编码缺失等)会每 10 分钟失败一次刷日志,修复根因是唯一正解(呼应 4.4)。
**⑦ 锁单补偿(5 分钟)**
扫「推送成功但金蝶锁定失败」的锁记录,重试加锁,**最多 5 次**;超过 5 次放弃,需运维调手动补偿端点 `POST /formal-settlement/retry-kingdee-bill-lock`(对应 FAQ 的「锁定失败」标签)。
### 4.3 审批自动同步开关(按业务域独立)
`thirdParty.bfi.autoSync.settlement`(结算单)/ `…payment`(付款)/ `…invoiceApply`(开票)/ `…receipt`(收票),默认全关。**代码默认全部关闭,必须在 Nacos 显式开启**;客商档案审批推送不受此开关控制(始终自动)。
### 4.3 运维注意
### 4.4 运维注意
- 所有定时任务带**分布式锁**:多实例部署只跑一份;手动按钮和定时共用锁,定时运行中手动触发会直接跳过(属正常)。
- 开了「failed 重推」后,持续失败的单据每 10 分钟会重试一次,**修复根因**(配置/数据)比反复观察更重要。
@@ -324,4 +365,4 @@
---
> 本文基于 2026-10-14 代码现状整理;系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。
> 本文基于 2026-10-14 代码现状整理;2026-10-19 依据当前代码核对并补充 4.2 定时任务运转逻辑(cron 默认值、错峰、终态不轮询、防呆与自愈行为)。系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。
@@ -41,7 +41,8 @@
| 项 | 规则 | 代码 |
|---|---|---|
| awrp_src_sys / systemsource / sourceSystem | Nacos `thirdparty.bfi.srcSys`,默认常量 `WLTMS` | BfiProperties(srcSys) |
| 组织 org/结算/付款/收款/申请组织 | 合同/申请单所属组织 → `SysCache.getDept()` → **部门金蝶组织编码**,空拦截 | `blade_contract_manage.organization_id` / 申请单 dept_id → `blade_dept.kingdee_dept_code`(KingdeeApBillSyncSupport.bfiOrgNumber L933-944) |
| 组织 org/结算/付款/收款组织(核算/资金类) | **统一取公司级编码**:项目业务部门沿部门树向上反查平台公司(`is_platform_company=1`)→ 平台公司 `blade_dept.kingdee_dept_code`;未归属平台公司/未维护编码直接拦截 | `kingdeeOrgNumber`(KingdeeApBillSyncSupport L976-999),项目经 `blade_project_apply.business_dept_id` |
| 申请部门类字段(A303-1 awrp_orgfield) | 申请部门自身 `kingdee_dept_code`(部门级业务单元编码,星瀚允许) | 申请单 dept_id → `blade_dept.kingdee_dept_code` |
| 币种 | 合同结算币种,`RMB→CNY` | `blade_contract_manage.settlement_currency`(bfiCurrency L952-956) |
| 往来户 | 名称 → `blade_customer_archive` 按 `full_name/short_name` 匹配(limit 1)取 `customer_code`,查不到报错 | queryCustomerCode L966-984 |
| 日期文本 | 统一 `yyyy-MM-dd HH:mm:ss` | bfiDateTimeText L922-925 |
@@ -133,7 +134,7 @@
| awrp_src_sys【必】 | 外部系统编号 | 配置 | `thirdparty.bfi.srcSys`(WLTMS) |
| awrp_src_type【必】 | 外部单据类型 | — | 常量 `TMS财务应付单` |
| billtypeid_number【必】 | 单据类型.编码 | — | 常量字典「服务采购应付」→ `ApFin_service_BT_S` |
| org_number / payorg_number【必】 | 结算/付款组织.编码 | 合同所属组织 | blade_contract_manage.organization_id → blade_dept.kingdee_dept_code |
| org_number / payorg_number【必】 | 结算/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) |
| bizdate【必】 | 单据日期 | 同步当日 | LocalDate.now() |
| asstacttype【必】 | 往来类型 | — | 常量 `bd_customer`(固定,供应商分支已注释) |
| asstact_number【必】 | 往来户.编码 | 合同乙方 | blade_contract_manage.party_b → blade_customer_archive.customer_code |
@@ -205,7 +206,8 @@
| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 |
|---|---|---|---|
| billtype_number【必】 | 单据类型 | 配置 | `thirdparty.bfi.payApply.billtype`(默认 `ap_payapply_oth_BT_S`) |
| settleorg/awrp_department/applyorg/payorg_number【必】 | 组织/部门 | 合同所属组织 | blade_contract_manage.organization_id → blade_dept.kingdee_dept_code |
| settleorg/applyorg/payorg_number【必】 | 结算/申请/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) |
| awrp_department_number【必】 | 部门.编码 | 项目业务部门自身 | kingdeeDeptNumber → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司;未维护拦截) |
| paycurrency/settlecurrency_number【必】 | 币种 | 合同结算币种(RMB→CNY) | blade_contract_manage.settlement_currency |
| applycause | 请款事由 | 按付款类型映射文案:项目垫资→「项目预付款申请」、进度垫资→「进度预付款申请」、结算付款→「结算付款申请」 | blade_payment_application.payment_type |
| exratetable_number【必】 | 汇率表 | 配置(**此接口实际送**) | `thirdparty.bfi.payApply.exratetable`(默认 `01`) |
@@ -228,7 +230,8 @@
| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 |
|---|---|---|---|
| billno【必·候选键】 | 单据编号 | 开票申请单号(多张加张次序号) | blade_invoice_application.application_no(多张 + "-" + blade_invoice_application_sheet.sheet_no) |
| orgid_number / awrp_orgfield_number【必】 | 组织/申请部门 | 申请部门 | blade_invoice_application.dept_id → blade_dept.kingdee_dept_code |
| orgid_number【必】 | 组织.编码 | 申请关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司金蝶组织编码(绑定税号的核算组织) |
| awrp_orgfield_number【必】 | 申请部门.编码 | 申请部门 | blade_invoice_application.dept_id → blade_dept.kingdee_dept_code(部门级业务单元编码) |
| billdate【必】 | 单据日期 | 申请日期(空取当天) | blade_invoice_application.application_date |
| billproperties / taxationstyle / buyerproperty | 单据性质/征税方式/购方类型 | 固定 | `"1"` / `"0"` / `"0"` |
| invoicetype【必】 | 发票种类 | 发票类型:数电专票→`08xdp`,其余→`10xdp` | blade_invoice_application.invoice_type |
@@ -293,7 +296,7 @@
| invoicetypef7_number【必】 | 发票类型.编码 | 票易通原始编码(缺失拦截并按票号回源 A801-1 补填) | blade_kingdee_invoice_pool.invoice_type_code |
| paymenttype【必】 | 付款方式 | 固定现购 | `CASH` |
| asstacttype【必】 | 往来类型 | 统一固定客户(与应付单/付款申请同口径) | `bd_customer` |
| org_number / currency_number【必】 | 组织/币种 | 分摊应付结算单关联合同 | blade_contract_manage.organization_id / settlement_currency |
| org_number / currency_number【必】 | 组织/币种 | 分摊应付结算单关联合同 → 项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司编码 / settlement_currency |
| receivablessupp_number【必】 | 往来户.编码 | 合同乙方→客商编码 | blade_contract_manage.party_b → customer_code |
| exratetable_number【必】 | 汇率表 | 固定 | `"01"` |
| asstactname / buyertin / sellertin【必】 | 销方名称/购销方税号 | 票池镜像票面 | blade_kingdee_invoice_pool.issuer_name / buyer_tax_no / saler_tax_no |
@@ -401,5 +404,6 @@ multipart 上传:`file`=文件流,`args`=JSON `{entityNumber, billPkId, cont
| 4 | A301-2 claimusers | 只读 Nacos 配置,方法入参(当前登录人姓名)已不参与取值;billstatus 字段存在但不传 |
| 5 | A801-1 expenseStatus | 同步**不下推**(常量已定义未使用),「在用/已用票随窗口刷新」,可选票过滤在本地 SQL |
| 6 | 应付 asstact_number | 取**合同乙方 party_b**(payer_name 仅应收侧使用) |
| 7 | 组织编码层级 | 核算/结算/付款/收款/开票**组织类字段已统一改为公司级编码**:`kingdeeOrgNumber`(L976-999)按项目业务部门沿部门树上溯 `is_platform_company=1` 平台公司取其 `kingdee_dept_code`;仅 A303-1 申请部门(awrp_orgfield)保留部门级编码。部门级编码(如工业物流中心 01.02.05.06.01.015)不再直接作为结算/付款组织送出,自动替换为其平台公司编码(如国安运输 01.02.05.06.01);前置数据要求见《2026-10-16-金蝶组织编码部门数据维护清单》 |
> 校验提醒:以上配置键的**环境实际值**以 Nacos 为准(本文只列键名与代码默认值);blade_* 表列名按 Entity 驼峰↔下划线约定标注,个别快照列以建表 SQL 为准。
@@ -0,0 +1,290 @@
# 结算调整单接入金蝶设计(未推送自动推 · 已推送手工冲销后重推新单)
> 整理日期:2026-10-16
> 目标:把「结算调整单」正式接入金蝶链路。核心约束——**原正式结算单一旦推送过金蝶,其本地数据一律冻结不动**;调整后的数据落在重推时新建的一张正式结算单上,用它向金蝶生成**全新**的财务应付/应收单。
> 依据:`2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).md`、`2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md`、`2026-10-05-审批通过自动同步金蝶设计(事件触发+定时重推+手动兜底).md`、`2026-10-09-金蝶单据锁定与解锁业务流程(...).md`、当前代码(探索核实)。
---
## 1. 背景与目标
结算调整单(`blade_settlement_adjustment`)用于在正式结算单审批通过后调整其费用项目与金额。目前它只有本地审批与一个假的 `repush`(拼 `K3AP+时间戳`),并未真正接入金蝶。
需要落地的三段操作:
| 操作 | 内容 |
|---|---|
| 操作 1 新增调整单 | 只允许选**审核通过**且**未下推付款申请**的正式结算单;自动回带客商、调整类型、合同等 |
| 操作 2 审批通过 | 回写/记录调整 → 按原结算单金蝶状态分流(未推送→推原单;已推送→不动金蝶,提示财务手工冲销) |
| 操作 3 重新推送(高危) | 旧应付/应收单已在金蝶冲销后,把**调整后的数据**以**全新来源单据号**推成一张新的财务应付/应收单 |
> 全程交互使用中文。
## 2. 名词与接口速查
| 代号 | 含义 |
|---|---|
| A102-2 | 财务应付单**保存**(推送) |
| A102-4 | 财务应付单**查询**(幂等预检;返回 `BfiApBillVO`,含 `iswrittenoff` / `hadwrittenoff`) |
| A302-2 | 财务应收单**保存**(推送) |
| A302-4 | 财务应收单**查询**(幂等预检,同上) |
| 防重键 | 金蝶来源单据号 `awrp_src_code`,由 `ExternalSrcCodeSupport.buildSrcCode(mainCode, partCodes)` 下划线拼接,规则 `结算单号_明细单据号` |
| 正式结算单 | `blade_formal_settlement`,单号 `FJS`(应付)/`SJS`(应收) + `yyyyMMdd` + 4 位流水(`nextNo`) |
| 结算调整单 | `blade_settlement_adjustment` |
## 3. 现状与差距(代码核实)
| # | 位置 | 现状 | 差距 |
|---|---|---|---|
| 1 | `SettlementAdjustmentServiceImpl.approve()`(约 221-261) | 仅改本地:回写原结算单费用、写变更记录、置 `approved`;`setKingdeeSyncStatus(formal.getKingdeeSyncStatus())`(推送前快照) | 未触发金蝶推送;未回填推送后的真实状态,导致前端「重新推送」按钮(条件 `approved && kingdeeSyncStatus='synced'`)不出现 |
| 2 | `SettlementAdjustmentServiceImpl.repush()`(约 207-217) | 假实现:拼 `K3AP+时间戳` 直接置 synced | 未校验冲销、未生成新单、未真正推送 |
| 3 | `FormalSettlementServiceImpl.syncKingdee/doSyncKingdee`(640/805) | 真实推送 + 落锁 + 失败重推链路;`doSyncKingdee` 在 `synced` 时**幂等早退** | 重推前须重置状态,否则被早退 |
| 4 | `KingdeeApBillSyncSupport.pushApBill`(220)/`queryApBillExistNos`(157) | A102-4 预检**不区分是否已冲销** | 冲销后同键重推会被预检判为"已存在"而跳过 |
| 5 | `BfiApBillVO` | 已含 `iswrittenoff`(154)/`hadwrittenoff`(158) | 尚未用于冲销判定 |
| 6 | `SettlementAdjustment` 实体 / `SettlementAdjustmentVO` | 实体无 `kingdeeBillNo`;VO 有(20) | 金蝶单号无处落库 |
| 7 | `IKingdeeBillLockService` | `lockAfterPush` / `checkEditable`;**冲销不触发解锁回调** | 原单锁记录会一直停在 `locked` |
| 8 | `IKingdeeBillLockService.checkEditable` | 仅开票申请、付款申请调用;**正式结算单未调用** | 正式结算单的编辑保护主要在前端 + `voidBill` 的 `synced` 拦截 |
## 4. 关键决策(已确认)
| 决策 | 结论 |
|---|---|
| **A 原单去向** | 已推送场景下**原单业务数据零改动**(费用/金额/状态全不动),只加 1 条变更记录 + 标记 + 解锁;新单是**全新独立单**,原单的下游关联(开票申请/收款认领/对账)**不迁移**,留在原单 |
| **B 展示标记** | 原单标「已调整」;新单标「调整后单」并显示原单号 |
| **B1 建单时机** | 「重新推送」时新建,审批阶段不动 |
| **B2 新单编号** | 走 `nextNo` 正式流水(`FJS`/`SJS` + 日期 + 4 位),**不**加后缀 |
| **B3 原单处理** | 保持原状,不做作废/删除 |
| **B4 调整单挂载** | 重推后调整单**改挂新单** |
| **B5 来源预结算** | 新单**重建** `FormalSettlementSource` 行(接续原单来源) |
| **B6 范围** | 应付、**应收都要做**(应付 A102-2/A102-4,应收 A302-2/A302-4) |
| **B7 列表** | 新旧两张单并存,带标识展示 |
| **C 冲销判定** | 只认 `hadwrittenoff=true`;部分冲销也放行 |
| **D 原单旧锁** | 重推时把原单旧锁标记为 `unlocked`,不改其它 |
| **E 未推送场景** | 审批通过后**自动推**(复用既有 `KingdeeBillApprovedEvent` 机制) |
| 新单状态 | 克隆后直接置 `approvalStatus=approved`,不再走审批 |
| 原单变更记录/unlock 时机 | 变更记录审批时写;`unlock` + `已调整`标记在**重推时**执行(审批时财务可能还没冲销) |
| **选单预检** | 操作 1 选定结算单后**预检**其金蝶冲销状态(软提示,不硬拦截);金蝶不可用时降级放行 |
| **重推冲销闸门** | 真正的**硬拦截**保留在操作 3 重推;预检不替代闸门 |
## 5. 总体方案
### 5.1 流程图
> 操作 1 选单后会先做一次金蝶冲销**预检(软提示,不阻断)**,见第 6.2 节。
```
┌── 原结算单 kingdeeSyncStatus ──┐
操作2 调整单审批通过 ──────────┤ │
▼ ▼
未推送(unsynced) 已推送(synced)
│ │
① 回写原单费用/金额 ① 原单业务数据【零改动】
② 原单写变更记录 ② 原单仅写 1 条变更记录
③ 发布 KingdeeBillApprovedEvent ③ 调整单置 approved
(formal-settlement, 原单id) ④ toast:原结算单已推送金蝶,
④ AFTER_COMMIT → syncKingdee(原单) 请财务在金蝶手工完成旧应付单冲销处理
⑤ 回填调整单 synced + kingdee_bill_no
│ │
│ 【财务在金蝶手工冲销旧应付/应收单】
│ │
│ 操作3 重新推送(高危)
│ │
│ ① 校验调整单 approved
│ ② 旧键查 A102-4/A302-4 → 要求 hadwrittenoff=true
│ ③ 克隆原单 → 新正式结算单(+应用调整明细、重建 source)
│ ④ 新单号组新键推 A102-2/A302-2
│ ⑤ 新单落锁 + 附件
│ ⑥ 调整单改挂新单 + 回填 kingdee_bill_no/synced
│ ⑦ 原单:标记「已调整」+ 旧锁 unlocked
│ ⑧ 写变更记录;失败置 failed 可重试
▼ ▼
金蝶应付/应收单 金蝶【全新】应付/应收单
(同原单号来源) (新单号来源,旧单已冲销)
```
### 5.2 三条不变式
1. **原单一经推送金蝶即冻结**:已推送场景下原单除「变更记录 / 标记 / 解锁」外,任何业务字段与状态均不改。
2. **调整后的金额只体现在新单**:未推送场景作用于原单;已推送场景作用于重推时克隆出的新单。两者共用同一段「应用调整到结算单」的逻辑。
3. **重推必须锚定金蝶侧已冲销**:以 A102-4/A302-4 的 `hadwrittenoff=true` 为唯一放行条件,避免重复生成应付/应收单。
## 6. 操作 1|新增调整单
### 6.1 选单来源(保持现状)
选单来源 `candidateFormalSettlements`(`SettlementAdjustmentServiceImpl` 约 98)已符合要求,不改:
- 仅 `approvalStatus = approved` 的正式结算单;
- 排除已下推付款申请的单(`tailPaymentSettlementIds`,约 324);
- 排除收付完成/尾款等不可调整(`adjustableFormalSettlement`,约 295);
- 保存时自动回带客商、调整类型、合同、原结算金额等。
### 6.2 选单后金蝶冲销预检(新增,软提示)
选定关联结算单号、回带信息后,前端调后端**预检接口**:
1. 若结算单**未推送金蝶**(`kingdeeSyncStatus != synced`)→ 返回"未推送",正常继续,后续按设计文档走审批自动推分支;
2. 若**已推送**→ 按防重键(`正式结算单号_结算明细单据号`)批量调 A102-4(应付)/ A302-4(应收),读 `hadwrittenoff`:
- `hadwrittenoff = true` → 返回"已推送·旧单已冲销",提示"旧应付/应收单已冲销,审批后可重新推送新单";
- `hadwrittenoff != true`(含查询不到)→ 返回"已推送·旧单未冲销",给出**醒目提示**:"该结算单已推送金蝶且旧单尚未冲销,审批通过后需先由财务在金蝶完成冲销,才能重新推送"。
**口径**:
- 预检为**软提示**,不阻断建单(默认建议);是否升级为硬拦截由业务另行确认。
- **降级**:金蝶查询超时/不可用/接口异常时,预检返回"未校验",**不阻断**建单与回带,由后续审批分流与重推闸门兜底。
- 预检结果**只用于提示**,不作为后续环节的结论缓存:审批(操作 2)、重推(操作 3)各自按**当下**状态重新判定。
> 为什么不把硬拦截放在选单:冲销是金蝶侧**不可逆/需人工撤销**的对外操作,而审批只是 TMS 内部决策。若选单即要求已冲销,会出现"旧单已冲销、调整单却被驳回/删除"的**应付单空档**(详见第 13 节)。
## 7. 操作 2|审批通过
### 7.1 未推送场景(原单 `kingdeeSyncStatus != synced`)
沿用现有 `approve()` 逻辑并补推送:
1. `applyAdjustmentToSettlement(原单, 调整单)`:回写费用项目、明细、合计、重算结算金额(**抽成公共方法**);
2. 原单追加变更记录、挂载调整单;
3. 调整单置 `approved`;
4. 事务提交后发布 `KingdeeBillApprovedEvent("formal-settlement", 原单id)`,由既有 `KingdeeApprovalSyncListener`(AFTER_COMMIT + 域开关 + 全量 try/catch)调 `formalSettlementService.syncKingdee(原单id)` 完成 A102-2/A302-2 推送 + 落锁;
5. 推送成功回填调整单 `kingdeeSyncStatus=synced` + `kingdeeBillNo`;失败置 `failed`(不改「已审批」结果,进入重推循环)。
> 复用 `2026-10-05-审批通过自动同步金蝶设计` 的事件机制,不另写推送路径;自动推送失败不影响审批结果。
### 7.2 已推送场景(原单 `kingdeeSyncStatus = synced`)★ 关键约束
**原单业务数据一律不动**,只做:
1. 原单追加 1 条变更记录:`结算调整单XX审批通过,调整金额XX,待重推金蝶`;
2. 调整单置 `approved`(`kingdeeSyncStatus` 保持 `synced` 快照,前端据状态显示「重新推送」入口);
3. 返回前端 toast 提示:**「原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付单冲销处理」**。
> 不做:不回写原单费用/金额、不修改旧应付、不自动推送新单、不作废原单。
## 8. 操作 3|重新推送(高危)
`repush(调整单id)` 重写为:
1. **前置校验**:调整单 `approvalStatus = approved`;原单存在。
2. **冲销校验(硬闸门)**:取调整单号 + 原单号,按旧键(`原结算单号_明细单据号`)查 A102-4(应付)/ A302-4(应收);要求存在且 `hadwrittenoff = true`,否则抛出「原应付/应收单尚未在金蝶冲销,禁止重推」。
3. **克隆新正式结算单**:`cloneFormalSettlement(原单, 调整单)`
- 新号由 `nextNo` 取正式流水(`FJS`/`SJS`+日期+4 位);
- 克隆明细、明细费项、汇总费项、发票、汇率等;
- 调 `applyAdjustmentToSettlement(新单, 调整单)` 应用调整(金额 = 原单 + 调整);
- **重建** `FormalSettlementSource`(接续原单来源预结算);
- `approvalStatus = approved`、`kingdeeSyncStatus = unsynced`、`originFormalSettlementNo = 原单号`;
- 附件复制/挂载。
4. **推送新单**:新单号天然组成新防重键(`新结算单号_明细单据号`),调 `syncKingdee(新单id)`(内部 A102-2/A302-2),**推前把新单状态置 `unsynced`** 以免被 `doSyncKingdee` 的 `synced` 早退。
5. **落锁**:新单 `lockAfterPush`(`TARGET_AP_BILL`/`TARGET_AR_BILL`)+ 附件上传。
6. **改挂 + 回填**:调整单 `formalSettlementId/No` 改指新单,回填 `kingdeeBillNo` + `kingdeeSyncStatus=synced`。
7. **原单收尾**:标记 `adjustStatus='已调整'` + 旧锁记录置 `unlocked`;追加重推变更记录。业务字段不动。
8. **失败处理**:新单 `kingdeeSyncStatus=failed` + 失败原因,支持复用 `retryKingdeeFailed` 重试(重试即从第 4 步继续,克隆已存在则跳过,注意幂等)。
## 9. 关键机制
### 9.1 防重键与新单号
- 金蝶 `awrp_src_code = 结算单号_明细单据号`。因为重推**新建了真实结算单记录**(新 `FJS/SJS` 号),新键天然与原键不同,**无需再额外拼调整单号**——早期「拼调整单号改防重键」的方案已被「新建结算单」覆盖。
- 旧键(原结算单号)用于查冲销;新键(新结算单号)用于生成全新应付/应收单。
### 9.2 冲销判定
- 只认 `hadwrittenoff = true`(`BfiApBillVO` 158 / `BfiArBillVO` 138)。部分冲销同样放行。
- `iswrittenoff` 不单独作为放行依据。落地前需用 Apifox 实测 A102-4/A302-4 确实回传 `hadwrittenoff`。
- **两处判定,用途不同**:操作 1 选单预检 = 软提示(第 6.2 节);操作 3 重推 = **硬闸门**(第 8 节)。预检不替代闸门——冲销在金蝶可被撤销,且建单到重推期间状态可能变化。
### 9.3 克隆新正式结算单
- 复用现有「保存/组装」逻辑而非另写:明细、费项、汇总、来源、附件、汇率、收付款单位等按原单快照复制。
- 新单不再走审批流(直接 `approved`),因此不写审批变更记录以外的流程痕迹;是否补一条「由调整单XX生成」变更记录可选。
### 9.4 金蝶锁
- 新单推送成功 → `lockAfterPush` 落锁,状态与既有一致。
- 原单旧锁:重推时置 `unlocked`(决策 D)。因正式结算单未调用 `checkEditable`,解锁主要影响前端标签,不构成后端编辑漏洞。
### 9.5 失败与重试
- 复用 `markKingdeeSyncFailed` / `retryKingdeeFailed` 模式:失败落 `failed` + 原因,可被定时重推任务扫描。
- 克隆步骤需幂等:以 `originFormalSettlementNo + 调整单` 作为唯一判断,避免重试时重复建单。
### 9.6 展示标记
| 对象 | 标记 | 来源 |
|---|---|---|
| 新正式结算单 | 「调整后单」(并显示原单号) | `origin_formal_settlement_no` 非空 |
| 原正式结算单 | 「已调整」 | `adjust_status = 'adjusted'` |
| 结算调整单 | 金蝶状态 / 金蝶单据号 | `kingdee_sync_status` / `kingdee_bill_no` |
## 10. 数据模型改动(DDL)
新增脚本 `doc/sql/transport/blade_settlement_adjustment_kingdee_20261016.sql`(多数据库脚本同步,排序规则 `utf8mb4_general_ci`):
```sql
-- 结算调整单:落金蝶单据号
ALTER TABLE blade_settlement_adjustment ADD COLUMN kingdee_bill_no VARCHAR(64) NULL COMMENT '金蝶单据号';
-- 正式结算单:来源原单号(新单) + 调整标记(原单)
ALTER TABLE blade_formal_settlement ADD COLUMN origin_formal_settlement_no VARCHAR(64) NULL COMMENT '来源原正式结算单号(结算调整重推生成)';
ALTER TABLE blade_formal_settlement ADD COLUMN adjust_status VARCHAR(32) NULL COMMENT '调整状态:adjusted=已被结算调整';
```
推送金蝶与重新推送合并为**单一按钮**(按金蝶状态分流),复用既有「重新推送」菜单,无需新增菜单 SQL。
## 11. 改动清单(文件级)
| # | 文件 | 改动 |
|---|---|---|
| 1 | `blade_service_api/.../pojo/entity/SettlementAdjustment.java` | 加 `kingdeeBillNo` |
| 2 | `blade_service_api/.../pojo/entity/FormalSettlement.java` | 加 `originFormalSettlementNo`、`adjustStatus` |
| 3 | `blade_service_api/.../pojo/vo/SettlementAdjustmentVO.java`、`FormalSettlementVO.java` | 补展示字段/翻译 |
| 4 | `SettlementAdjustmentServiceImpl.approve()` | 未推送→发 `KingdeeBillApprovedEvent` + 回填;已推送→仅变更记录 |
| 5 | `SettlementAdjustmentServiceImpl.repush()` | 重写:冲销校验 → 克隆新单 → 推 → 落锁 → 改挂 → 回填 → 原单标记/解锁 → 失败置 failed |
| 6 | `SettlementAdjustmentServiceImpl` | 抽公共方法 `applyAdjustmentToSettlement(结算单, 调整单)` |
| 7 | `FormalSettlementServiceImpl`(或新 support) | 新增 `cloneFormalSettlement(...)`(含重建 `FormalSettlementSource`) |
| 8 | `FormalSettlementServiceImpl` / `KingdeeApBillSyncSupport` | 冲销查询(A102-4/A302-4 读 `hadwrittenoff`)封装 |
| 9 | `FormalSettlementWrapper` / 列表 Mapper | 回填 `adjustStatus` / `originFormalSettlementNo` |
| 10 | 前端 `settlement-adjustment.vue`、`settlementAdjustment.js` | 金蝶状态/单号列、「推送金蝶 / 重新推送」合并按钮(未同步→推原单;已同步→冲销后重推新单,重推前冲销预检);选单后调预检接口并展示软提示 |
| 11 | 前端 `formal-settlement.vue` / `formal-settlement-table-panel.vue` / 详情 | 「调整后单 / 已调整」标识、查看弹窗补重推入口 |
| 12 | `doc/sql/...` | DDL + 菜单 SQL |
| 13 | `SettlementAdjustmentServiceImpl` + `SettlementAdjustmentController` | 新增选单预检接口(返回 未推送 / 已推送已冲销 / 已推送未冲销;金蝶异常降级返回未校验) |
## 12. 边界场景推演
| 场景 | 行为 |
|---|---|
| 调整单未审批就重推 | 前置校验拒绝(要求 approved) |
| 旧应付单未冲销 | A102-4 返回 `hadwrittenoff=false`/不存在 → 拒绝,提示先冲销 |
| 部分冲销 | `hadwrittenoff=true` → 放行 |
| 旧应付单被删除(非冲销) | 查询不到 → 拒绝(不满足 `hadwrittenoff=true`) |
| 已推送场景审批后财务未冲销 | 重推被拦;原单与新单均未产生重复应付单 |
| 重推推送失败 | 新单置 `failed` + 原因;原单已标「已调整」+ 解锁;重试从推送步骤续上 |
| 重推成功但落库前崩溃 | 重试时幂等判断:已存在同一来源新单则跳过克隆,仅补推/补回填 |
| 应收结算单(A302-2/A302-4) | 走同一套逻辑,应收侧下游关联不迁移(决策 A) |
| 原单锁残留 | 重推时统一置 `unlocked` |
| 未推送场景自动推失败 | 审批仍成功;调整单 `failed`,走重推循环 |
| 选单时结算单已推送且旧单未冲销 | 预检返回"未冲销"并**软提示**(不阻断建单);审批后需先冲销再重推 |
| 选单时金蝶超时/不可用 | 预检**降级为"未校验"**,不阻断建单;由审批分流与重推闸门判定 |
| 建单时未推送、审批时已被推送到金蝶 | 审批按**当下**状态走"已推送"分支:不自动推、提示财务冲销;预检结论不沿用 |
| 选单时已冲销、重推前被金蝶撤销冲销 | 重推硬闸门重新查询 `hadwrittenoff` → 拦截,提示先冲销 |
## 13. 风险与待验证
1. **A102-4/A302-4 的 `hadwrittenoff` 实际回传**:必须先用 Apifox 实测,否则整套冲销判定可能推翻。
2. **克隆新单**:`FormalSettlementSource` 重建会牵动预结算占用(`PreSettlement.formalSettlementNo`),是最易产生脏数据的一步,需单独小步验证。
3. **原单下游关联不迁移**(开票申请/收款认领/对账):新单为全新独立单,应收场景下新单将显示「未开票/未收款」,属已确认口径,但需业务知悉。
4. **重推幂等**:克隆需以来源唯一键保证不重复建单,验证重试与并发(同一调整单重复点击)。
5. **正式结算单无 `checkEditable`**:解锁不构成后端漏洞,但需确认前端在 `approved` 下仍不暴露编辑入口,避免原单被改。
6. **冲销前置到选单的风险(故不做硬拦截)**:冲销是金蝶侧不可逆/需人工撤销的对外操作,审批是 TMS 内部决策。若选单即硬拦截(要求已冲销),会出现"旧单已冲销、调整单却被驳回/删除"的**应付单空档**,需人工在金蝶恢复冲销。故预检仅作软提示,硬闸门留在重推。
7. **预检接口不可成为单点**:金蝶不可用时必须降级放行,否则远程依赖会阻断调整单的建单与回带。
---
## 14. 落地实现备注(2026-10-16)
1. **未推送场景回填**:推送成功/失败分别由 `FormalSettlementServiceImpl.doSyncKingdee` / `markKingdeeSyncFailed` 按 `formal_settlement_id + approval_status=approved` 回填调整单 `kingdee_sync_status`(synced/failed)与 `kingdee_bill_no`,复用 §7.1 的事件自动推送链路,未新增推送路径。
2. **重推为「非事务编排 + 段内事务」**:`repush` 依次执行 —— 冲销硬闸门(旧键查 A102-4/A302-4,`hadwrittenoff=true`)→ 自代理事务 `cloneAndApplyForRepush`(克隆新单 + 应用调整,原子)→ `syncKingdee(新单)` 推送 → 成功后调整单改挂新单 + 原单标记「已调整」+ 旧锁解锁。推送失败**不回滚已克隆新单**,重试按 `origin_formal_settlement_no = 调整单当前关联单号` 幂等复用,从推送步骤续上(§8.8、§9.5)。
3. **改挂时机**:调整单改挂新单在**推送成功之后**,故成功后再点重推会被冲销闸门拦截(无副作用);如需彻底隐藏,前端按「关联单是否克隆单」判断。
4. **新单不克隆发票**:遵循决策 A「下游关联不迁移」,新单 `invoiceStatus=unreceived`、`paymentStatus=unpaid`,`invoiceAmount/paidAmount/appliedPaymentAmount` 归零,发票不复制。
5. **按钮与菜单**:「推送金蝶」与「重新推送」合并为**单一入口**(按金蝶状态分流:未同步/失败→推原结算单;已同步→冲销后重推新单),统一复用既有菜单 `settlement_adjustment_repush`,不新增菜单。
6. **待实测**:A102-4/A302-4 的 `hadwrittenoff` 实际回传(§13.1);克隆新单对 `PreSettlement` 占用的影响(§13.2)。
@@ -0,0 +1,190 @@
# 结算调整单操作流程说明(分场景)
> 整理日期:2026-10-17
> 定位:面向操作者的流程说明,只讲"谁在哪个页面做什么、系统发生什么、结果是什么",不含代码细节。
> 依据:`2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md` 及当前已落地实现。
---
## 一、结算调整单是什么
正式结算单**审批通过后不允许直接改金额**,一切费用调整都必须通过「结算调整单」完成。
- **作用**:对一张已审批通过的正式结算单,调整其费用项目和金额(调增、调减、新增手工费用)。
- **单号规则**:`TZ + 日期 + 4 位流水`,保存草稿时自动生成。
- **只能手工新增**:系统不会自动生成调整单,全部由业务人员在页面发起。
- **没有作废功能**:调整单一旦提交走审批,不支持作废;错了只能走"驳回 → 修改"或重新建单。
- **推送金蝶按钮是高危操作**:点击前有二次确认,请确认金蝶侧状态后再操作。
---
## 二、状态与可用操作总览
### 2.1 状态流转
```
新增/保存草稿 ──→ 草稿 ──提交──→ 审批中 ──审批通过──→ 审批通过(终态)
↑ │
│ 驳回
└──── 已驳回 ←───┘
(可改后重新提交)
```
- 草稿、已驳回:可编辑、可删除(草稿)、可提交。
- 审批中:只能"通过"或"驳回"。
- 审批通过:终态,只剩金蝶推送相关操作。
### 2.2 各状态下的按钮
| 状态 | 可用操作 | 说明 |
|---|---|---|
| 草稿 | 编辑 / 删除 / 提交 | 删除为物理删除,含明细 |
| 已驳回 | 编辑 / 提交 | 须先"编辑"保存一次(保存自动回到草稿)才能提交,直接提交会被拒绝 |
| 审批中 | 通过 / 驳回 | 审批人操作 |
| 审批通过 | 推送金蝶 / 重新推送(合一按钮)| 按金蝶状态自动分流,高危确认 |
### 2.3 金蝶同步状态(调整单上的展示字段)
| 状态 | 含义 |
|---|---|
| 未同步 | 原结算单还没推过金蝶,或推送尚未发生 |
| 已同步 | 已在金蝶生成应付/应收单,调整单上可看到金蝶单据号 |
| 生成失败 | 推送失败,可用"推送金蝶"重试或等定时重推 |
---
## 三、通用操作说明(每个操作:前置条件 → 系统动作 → 结果)
### 3.1 新增调整单
| 项 | 说明 |
|---|---|
| 入口 | 结算调整单列表 → 新增 |
| 前置 | 必须先选定一张**可调整的正式结算单**,条件缺一不可:① 审批通过;② 未完成收款/付款;③ 非尾款结算;④ 未发起尾款付款/付款申请。不满足的单在选单列表里根本搜不到 |
| 系统动作 | 选定结算单后自动回带:客商、调整类型(应收/应付)、合同、原结算金额、原单费用明细;同时做一次**金蝶冲销预检(软提示)** |
| 预检提示 | ① 未推送金蝶 → 正常继续;② 已推送且旧单已冲销 → 提示"审批后可重新推送新单";③ 已推送且旧单未冲销 → 醒目提示"审批后需先由财务在金蝶完成冲销,才能重新推送";④ 金蝶查不到/超时 → 提示"未校验",**不阻断建单** |
| 结果 | 生成一张草稿,获得单号 |
> 预检只是提示,不拦人;真正的拦截在"重新推送"那一步。填单时必须至少录入一条调整费用,否则无法提交。
### 3.2 保存草稿 / 编辑 / 删除
- 草稿和已驳回的单可反复编辑;删除仅限草稿,删除后单号作废、不留痕迹。
- 手工费用行(不对应原单明细的费用)与关联原单明细的费用行在明细里混排,提交时会校验关联完整性。
### 3.3 提交
- 草稿 / 已驳回 → 审批中,进入审批人的待办。
- 提交后单据锁定,提交人不能再改。
### 3.4 审批通过 ★ 核心分流点
审批人点"通过"后,系统按**原结算单是否推送过金蝶**自动走两条完全不同的路(见第四、五节场景 A / B):
- **原单未推送**:把调整直接回写到原结算单,然后自动推金蝶。
- **原单已推送**:原单数据一律不动,只留一条变更记录,等财务冲销后再人工重推。
> 注:审批接口会顺带返回自动推送的失败原因(如有),前端直接提示,不用去翻日志。
### 3.5 驳回
- 审批中 → 已驳回,驳回原因写入备注。
- 驳回后单据解锁,提交人可修改后重新提交,回到审批中。注意:重新提交前必须先点一次「编辑」并保存(保存会把单据重置回草稿),对已驳回单直接点提交会被拒绝。
### 3.6 推送金蝶 / 重新推送(合一按钮,高危)
列表和详情里只有一个按钮,系统按金蝶状态自动分流:
| 调整单金蝶状态 | 按钮实际行为 | 前置闸门 |
|---|---|---|
| 未同步 / 生成失败 | 推送其关联的**原结算单**到金蝶 | 无冲销要求 |
| 已同步 | **重新推送(高危)**:确认金蝶旧单已冲销后,克隆一张新正式结算单并推送 | 硬闸门:金蝶旧单必须已冲销,否则直接拒绝并提示先冲销 |
按钮点击均有高危二次确认弹窗。
---
## 四、分场景流程
### 场景 A:原结算单未推送金蝶(最常见、全自动)
适用:正式结算单审批通过了,但还没推到金蝶。
| 步骤 | 操作人 | 操作 | 系统动作 |
|---|---|---|---|
| 1 | 业务 | 新增调整单,选定结算单,录调整费用,保存草稿 | 回带信息、生成 TZ 单号 |
| 2 | 业务 | 提交 | 草稿 → 审批中 |
| 3 | 审批人 | 审批通过 | ① 调整直接回写原结算单(费用、明细、合计、结算金额全部更新);② 原单追加变更记录;③ 调整单 → 审批通过;④ 事务提交后**自动触发金蝶推送**(走正式结算单既有的推送链路:生成财务应付/应收单 + 单据落锁 + 附件上传) |
| 4 | 系统 | — | 推送成功:调整单回填"已同步 + 金蝶单据号";推送失败:调整单置"生成失败"+ 原因,**审批结果不受影响**,转入场景 D 处理 |
结束后:原结算单即调整后的金额,金蝶里是一张含调整金额的应付/应收单。
> 注意:审批后自动推送受系统开关控制。若环境未开启自动同步,审批仍成功,但需人工在列表点"推送金蝶"完成推送。
### 场景 B:原结算单已推送金蝶(需财务在金蝶冲销后重推)
适用:正式结算单已在金蝶生成应付/应收单。核心约束:**原单一经推送金蝶即冻结,本地数据一律不动**。
| 步骤 | 操作人 | 操作 | 系统动作 |
|---|---|---|---|
| 1 | 业务 | 新增调整单(选单时预检会提示"旧单未冲销"或"已冲销"),提交 | 同场景 A 步骤 1-2 |
| 2 | 审批人 | 审批通过 | ① 原结算单业务数据**零改动**,只追加 1 条变更记录("调整单XX审批通过,调整金额XX,待重推金蝶");② 调整单 → 审批通过,金蝶状态保持"已同步"快照("重新推送"按钮因此亮起);③ 前端提示:**"原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理"** |
| 3 | 财务 | **在金蝶系统内手工冲销**旧的应付/应收单 | TMS 不参与,这是金蝶侧的人工操作 |
| 4 | 业务 | 回到 TMS,点"重新推送(高危)"并二次确认 | 系统依次:① 硬闸门——按原结算单号去金蝶查旧单,**必须已冲销**(部分冲销也算已冲销),未冲销直接拒绝;② **克隆一张新的正式结算单**(全新 FJS/SJS 单号),把调整应用上去,直接置为审批通过(新单不再走审批流);③ 用新单号推送金蝶,生成**全新**的财务应付/应收单 + 落锁 + 附件;④ 调整单改挂到新单,回填"已同步 + 金蝶单据号";⑤ 原单标记"已调整",原单在金蝶的旧锁解锁 |
| 5 | 系统 | — | 完成。此时列表里**新旧两张正式结算单并存**,带标识区分(见第六节) |
失败处理:第 4 步若推送失败,已克隆的新单不回滚,新单置"生成失败";再点"重新推送"会**复用已克隆的新单**从推送步骤继续,不会重复建单(幂等)。调整单已改挂新单后再点重推,会被冲销闸门拦截,无副作用。
> 为什么第 2 步不自动推:金蝶侧已有一张旧单,直接再推会重复生成应付/应收单;必须先冲销旧单,且冲销只能由财务在金蝶完成,TMS 无法代办。
### 场景 C:审批驳回 → 修改后重新提交
1. 审批人在"审批中"单据上点"驳回",填写驳回原因。
2. 单据回到"已驳回",提交人可编辑(改费用、改金额),重新提交。
3. 提交后回到"审批中",等同重新走审批。
4. 若已驳回单不想调了:留置即可(无作废功能),或改对后再次提交。
> 已审批通过的单**无法驳回、无法作废、无法撤回**,录入时请谨慎;金额确认错的分析原因后新建调整单二次调整。
### 场景 D:金蝶推送失败
| 触发 | 现象 | 处理 |
|---|---|---|
| 场景 A 审批后自动推送失败 | 调整单状态"生成失败",审批接口直接返回失败原因,原结算单也有失败标记 | ① 等定时重推任务自动扫描重试;② 或人工在调整单列表点"推送金蝶"立即重试 |
| 场景 B 重推新单失败 | 新正式结算单"生成失败",调整单显示失败状态 | 再点"重新推送",系统复用已克隆的新单从推送步骤续推,不会重复建单 |
审批结果与推送结果相互独立:推送失败不影响"审批通过",只影响金蝶侧是否生成单据。
### 场景 E:边界情况速查
| 情况 | 系统行为 |
|---|---|
| 未审批就点重推 | 拒绝,要求先审批通过 |
| 金蝶旧单未冲销就点重推 | 硬拦截,提示先冲销 |
| 金蝶旧单被删除(非冲销)| 查不到旧单 → 同样拦截 |
| 部分冲销 | 视同已冲销,放行重推 |
| 审批时原单刚好已被推送到金蝶 | 按**当下**状态走场景 B,选单时的预检结论不作数 |
| 选单时金蝶超时/不可用 | 预检降级"未校验",不阻断建单 |
| 重推成功前原单旧锁残留 | 重推成功时统一解锁 |
| 应收结算单 | 与应付完全同一套流程,仅接口不同(应收 A302 系 / 应付 A102 系) |
---
## 五、单据标识与字段说明(列表里怎么认单)
| 单据/字段 | 含义 |
|---|---|
| 正式结算单标"**已调整**" | 该单已被调整单重推过,金额已作废(数据保留),不要再用它做下游业务 |
| 正式结算单标"**调整后单**"并显示原单号 | 这是重推克隆出的新单,金额 = 原单 + 调整 |
| 调整单"金蝶单据号" | 该调整最终落到金蝶哪张应付/应收单上 |
| 调整单"金蝶同步状态" | 见 2.3 |
| 调整金额 / 原结算金额 / 调整后金额 | 调整后金额 = 原结算金额 + 调整金额 |
---
## 六、重要业务口径(须知)
1. **原单冻结原则**:原结算单一经推送金蝶,TMS 本地对其零改动;调整金额只体现在重推克隆的新单上。
2. **新单是全新独立单**:原单的下游关联(开票申请、收款认领、对账)**不迁移**,留在原单;新单发票状态、收付款状态均从零开始(未收票、未付款)。应收场景下新单会显示"未开票/未收款",属正常现象。
3. **新单不走审批流**:克隆的新正式结算单直接是审批通过状态,无需再审批。
4. **冲销是财务的人工操作**:TMS 不自动冲销金蝶单据,也不会因为调整单驳回/删除而自动恢复冲销——所以建单阶段只做软提示,把硬拦截留在重推。
@@ -0,0 +1,284 @@
# 正式结算单预付整合设计(进度预付 + 项目预付核销)
> 关联文档:同目录《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》(改造前现状梳理)
> 设计日期:2026-10-18。范围:正式结算单(FormalSettlement)对两类预付的金额整合与付款记录关联显示。
> **落地后全链路逻辑见同目录《2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md》。**
> **v2 修订**:纳入评审意见——核销计入口径改为"仅已付款预付可核销"(待业务确认)、新增核销行迁移设计(预结算转正式 / 调整重推)、作废联动删行补全(含结算单作废/金蝶删单回调/预结算作废)、调整单校验与刷新、列表页 N+1 规避、对账与调整完成判定影响面、细节修正。见文末修订记录。
## 一、背景与目标
两条预付线的"单据流"已经打通(进度预付经预结算单继承、项目预付经核销组件落库),但**金额与记录**没有整合进正式结算单:
| # | 差距 | 现状锚点 |
|---|---|---|
| 1 | 结算合计区域看不到预付抵扣:原金额、已付金额(含预付/核销)、剩余未付金额 | `formal-settlement-editor.vue` 结算合计区只渲染费用明细合计(L99-180) |
| 2 | 项目预付核销金额不计入结算单 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus` | `calculatePaymentSummary` payable 分支只加来源预结算的预付快照(`FormalSettlementServiceImpl` L1881-1890) |
| 3 | 付款记录只显示尾款(`settlement_payment`)申请,进度预付/项目预付的已付款记录不关联显示 | `detail()` 查询付款申请固定 `paymentType=结算`(L339) |
| 4 | 核销行没有"流转"设计:预结算转正式、调整重推时核销行丢失或额度双占 | `cloneFormalSettlement` L641-708 不克隆核销行;编辑器新建固定重置 `[]`(L1410) |
| 5 | 各类作废不清理核销行,详情残留、批量刷新会把作废单核销计入已付 | `FormalSettlementServiceImpl.voidBill` L600-609、`voidByKingdee` L613-637、`PreSettlementServiceImpl.voidBill` L443 均无删行 |
**零表结构变更**:所需字段均已存在(`FormalSettlementSource` 预付快照、`SettlementProjectAdvanceWriteOff` 核销行、`PreSettlementAdvance` 预付台账),本设计只做计算口径、记录流转与展示整合。
## 二、已确认决策
| 决策 | 结论 | 状态 |
|---|---|---|
| D1 结算合计"扣除"的呈现 | `settlementAmount` **保持全额**(费用合计,开票匹配上限、金蝶 A102-2 应付单金额、结算调整单基线都不变)。结算合计卡片下增加汇总栏:**结算合计(原金额)/ 已付金额(含预付、核销)/ 剩余未付金额**,编辑态与只读态都显示。 | ✅ 已确认 |
| D2 核销计入口径(修订) | **仅已付款的项目预付可核销**:`listOptions` 选单过滤与 `replaceWriteOffs` 保存校验都增加 `kingdeeStatus='paid'` 条件(当前只要求 `approved`,L157)。核销时点钱已付出,核销金额同时计入 `appliedPaymentAmount` 与 `paidAmount` 属实付口径,不存在"审批未付却推成 paid"的失真,与付款记录行展示的底层实付一致。 | ✅ 已确认(方案②) |
| D3 付款记录关联显示 | 后端 detail 新增统一预付付款记录列表 `advancePayments`;前端在只读"付款信息"表格中**合并成一张表**,以"付款类型"列区分:进度预付 / 项目预付核销 / 结算尾款。 | ✅ 已确认 |
| D4 范围 | 只做正式结算单(FORMAL)侧汇总;金蝶预付抵扣字段(`y_billtype/y_billid/y_settleamt`)维持本期不传;应收(receivable)侧不动。**但 PRE→FORMAL 的核销行搬运在范围内**(见 D5)。 | ✅ 已确认 |
| D5 核销行流转原则 | **同一笔预付的核销在链路上只存在一份**:预结算转正式时来源 PRE 核销行转移为本单 FORMAL 行;调整重推时原单核销行迁移到新单。转移 = 删源行 + 落新行,同一事务。 | ✅ 已确认 |
| D6 作废联动删行 | 付款申请作废删其核销行(既有"作废联动"模式);正式结算单作废(`voidBill`/`voidByKingdee`)与预结算单作废(`voidBill`)补 `deleteBySettlement`,消除详情残留与汇总污染。 | ✅ 已确认 |
| D7 结算调整单兜底 | 调整落库(`SettlementAdjustmentServiceImpl` L384-388)后:①校验**核销合计 ≤ 新结算金额**,超出则拒绝并提示先调减核销;②调用 `refreshPaymentSummary` 重算汇总(含核销)。 | ✅ 已确认 |
| D8 影响面确认点 | ① 对账单 `copyHeader` 复制 `paidAmount`(`TransportReconciliationServiceImpl` L1325)→ 对账单已付口径将含核销,**按可接受设计**;② `isSettlementCompleted`(`SettlementAdjustmentServiceImpl` L468-471)判定 → 全额核销的结算单 `paymentStatus=paid`,将**不能再发起结算调整**,按"全额抵扣即结清"设计。历史数据问题明确不考虑。 | ✅ 已确认 |
## 三、金额口径
前提(D2 修订):核销行的底层付款申请必为 `kingdeeStatus='paid'`。
```
结算金额 settlementAmount = Σ 结算合计费用项 settlementAmount(不变)
已付金额 paidAmount = 尾款已付(直挂+关系表, approved)
+ Σ 来源预结算 advancePaidAmount(进度预付,已有)
+ Σ 本单核销行 writeOffAmount(项目预付,本次新增;行必对应已付款预付)
已申请付款 appliedPaymentAmount = 尾款申请中(直挂+关系表, reviewing)
+ Σ 来源预结算 advanceAppliedAmount(已有)
+ Σ 本单核销行 writeOffAmount(本次新增)
剩余可付 remainingPayableAmount = max(settlementAmount − paidAmount, 0)(已有公式,输入变了)
付款状态 paymentStatus = unpaid / partial / paid(已有公式,输入变了)
```
> 与进度预付口径的准确表述:进度预付在结算单上是"申请(advanceAppliedAmount)/ 实付(advancePaidAmount)"两条快照分开跟踪;项目预付在核销时点已完成实付(D2),故核销额计入实付。付款记录行的"已付款金额"(底层实付)与汇总栏"已付金额"必然一致,不存在同屏矛盾。
## 四、后端改动(tms-api / blade-transport)
### 4.1 汇总计入核销金额
**`FormalSettlementServiceImpl.calculatePaymentSummary(settlement, data)`**(L1864-1901):
`payable` 分支(L1881-1890)在累加来源预付快照之后追加:
```java
// 项目预付核销计入已付/已申请(D2:核销行底层预付必已付款,核销额即实付抵扣)
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));
```
**`PaymentSummaryData` record**(L1926-1929)新增组件:
```java
@param writeOffAmounts 结算单id → 核销金额合计(仅应付结算单;null 表示由纯计算按需查询)
..., BigDecimal receiptClaimPaid, Map<Long, BigDecimal> writeOffAmounts
```
同步修改两处构造调用:
- 批量版 `refreshPaymentSummaries`(L1544-1627):`hasPayable` 时按 `settledIds` **一次**查询核销行分组求和(4.5 新增方法),按结算单分发,避免 N+1;
- 单笔版包装方法(L1839-1858):传 `null` 走按需查询。
> 说明:`saveDraft` 保存核销后由 `refreshSettlementAmount`(内部 L1524 刷新)+ 新增的一次 `refreshPaymentSummary` **连续两次完整刷新是有意为之**——核销校验依赖 `refreshSettlementAmount` 重算后的最终结算金额,故 `replaceWriteOffs` 只能排在其后,二次刷新不能合并省略。后续优化前先读此注释。
### 4.2 保存核销后刷新汇总
**`FormalSettlementServiceImpl.saveDraft`**(L546-553):`replaceWriteOffs` 保持在 `refreshSettlementAmount`(L546)之后(校验用最终结算金额),其后追加:
```java
projectAdvanceWriteOffService.replaceWriteOffs(...); // L548-553 原位置不动
this.refreshPaymentSummary(settlement.getId()); // 新增:核销行计入汇总
```
### 4.3 核销行流转(D5)
**通用删行方法**(`ISettlementProjectAdvanceWriteOffService` 新增,事务内逻辑删除):
```java
/** 删除指定结算单上的核销行(配合转移/作废场景),返回被删行数 */
int deleteBySettlementIds(String settlementType, Collection<Long> settlementIds);
/** 按付款申请删除核销行,返回受影响的正式结算单id(settlementType=formal,供汇总刷新) */
List<Long> deleteByPaymentApplicationIds(Collection<Long> paymentApplicationIds);
```
**预结算转正式(`saveDraft` 完整建单路径)**:
1. 前端:编辑器 `confirmCandidates` 选定来源预结算后,对每个新选 `preSettlementId` 复用**既有** `preSettlement.getDetail(id)` 取 `projectAdvanceWriteOffs` 预填进本单核销组件(去重、金额 Number 化),用户可在弹窗中增删改;
2. 后端 `saveDraft`:在 `replaceWriteOffs`(L548)**之前**执行 `deleteBySettlementIds("pre", request.getSourcePreSettlementIds())`——先释放来源占用,`replaceWriteOffs` 的"不超可核销余额"校验才能通过预填金额;随后 `replaceWriteOffs` 将预填行落为本单 FORMAL 行。同一事务,失败整体回滚。
3. 轻量标记路径(`PreSettlementServiceImpl.formalSettlement(id)`,L550):不产生正式单实体,PRE 核销行原地保留(预结算实体仍是载体),待后续真正建正式单时按上述规则转移。
**调整重推(`cloneFormalSettlement`,L641-708)**:与既有"新单付款口径归零"哲学(L658 注释)冲突时以**额度正确性优先**——
1. 在来源克隆循环后,克隆原单活跃核销行到新单(`settlementId` 换新、`resetAuditFields`);
2. `deleteBySettlementIds("formal", List.of(sourceSettlementId))` 删原单核销行(原单 `adjustStatus=adjusted` 为历史存档,已付归零以新单为准,文档明示);
3. `refreshPaymentSummaries(List.of(原单id, 新单id))` 一次批量刷新两单(新单头 L662-664 置零的 applied/paid 由重算纠正为核销额)。
> 新单由克隆直接 `approved`(L647),无草稿编辑机会,故必须迁移而非"让用户重新核销"。
### 4.4 作废联动删行(D6)
| 触发点 | 改动 |
|---|---|
| `PaymentApplicationServiceImpl.voidBill`(L446-460,L458 `voidPreSettlementAdvance` 后) | `deleteByPaymentApplicationIds(List.of(entity.getId()))` → 返回的 formal ids 非空则 `formalSettlementService.refreshPaymentSummaries(ids)` |
| `FormalSettlementServiceImpl.voidBill`(L600-609) | `deleteBySettlementIds("formal", List.of(id))` |
| `FormalSettlementServiceImpl.voidByKingdee`(L613-637) | 同上 |
| `PreSettlementServiceImpl.voidBill`(L443) | `deleteBySettlementIds("pre", List.of(id))`(预结算作废前置"先作废预付"校验不受影响,核销行不阻断) |
金蝶反审核自动解锁置 `failed`(未作废)的场景**不删行**:预付申请仍是 `approved`,已核销行继续计入;人工作废后经上表路径回落。
存量数据提示:v2 之前保存的核销行可能对应"approved 未 paid"的预付,本次不做数据修正,如需口径清洗另出脚本(`replaceWriteOffs` 校验只约束新保存)。
### 4.5 核销服务与 Mapper
**`ISettlementProjectAdvanceWriteOffService` / `SettlementProjectAdvanceWriteOffServiceImpl`**:
- 4.3 两个删除方法(见上);
- `Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds)`——一次 group-by,供批量刷新与列表页(4.7)使用。
**`SettlementProjectAdvanceWriteOffMapper.java`(接口)+ `SettlementProjectAdvanceWriteOffMapper.xml`**:新增 `sumWriteOffBySettlementIds`(`is_deleted=0` + `settlement_type` + `settlement_id in (...)`,`GROUP BY settlement_id`),与既有 `sumWriteOffBySettlement`(XML L27-33)口径一致。
**选单与校验加 paid 条件(D2)**:
- `listOptions`(ServiceImpl L48-81):查询条件追加 `kingdeeStatus='paid'`;
- `replaceWriteOffs`(L124-193):在 `approvalStatus=approved` 校验(L157-159)后追加 `kingdeeStatus='paid'` 校验,提示文案"项目预付已付款后方可核销"。
### 4.6 detail 返回统一预付付款记录
**新 VO `SettlementAdvancePaymentVO`**(`blade-transport-api/pojo/vo/`,仿 `ProjectAdvanceWriteOffOptionVO` 风格,含 BladeX 许可头):
| 字段 | 说明 |
|---|---|
| `recordType` | `progress_advance` 进度预付 / `project_advance` 项目预付核销 |
| `paymentNo` | 进度预付 = `PreSettlementAdvance.advanceNo`(FKD 单号);项目预付 = 核销行 `paymentNo` |
| `appliedAmount` | 进度预付 = `appliedAmount`;项目预付 = 核销行 `writeOffAmount`(**本次核销金额**,非付款申请金额) |
| `paidAmount` | 进度预付 = `paidAmount`;项目预付 = 底层付款申请 `paidAmount`(D2 下 = 核销额,同屏一致) |
| `billStatus` | 进度预付 = 台账 `billStatus`;项目预付 = 付款申请 `approvalStatus` |
| `kingdeeBillNo` | 进度预付 = `kingdeeAdvanceNo`;项目预付 = 核销行 `kingdeeBillNo` |
| `createUserName` / `createTime` | `UserCache.getUserRealName(createUser)`(AGENTS.md 6.9);**createTime 统一取底层付款申请创建时间**:项目行用核销行已回填的 `paymentCreateTime`(`listBySettlement` 现成),进度行按 `advanceNo in (...)` 批量反查 `PaymentApplication.createTime`(一次 in 查询)。列表排序与展示均用该时间 |
**`FormalSettlementVO`** 新增 `@TableField(exist = false) private List<SettlementAdvancePaymentVO> advancePayments;`(L74 `paymentApplications` 旁)。
**`FormalSettlementServiceImpl.detail`**(L307-352)在加载 `projectAdvanceWriteOffs`(L323-324)后组装:
```java
vo.setAdvancePayments(this.listAdvancePayments(sources, vo.getProjectAdvanceWriteOffs()));
```
`listAdvancePayments` 私有方法:
1. **进度预付**:`PreSettlementAdvance` 按 `preSettlementId in 来源预结算ids`、`billStatus != 'voided'` 查询(注入 `PreSettlementAdvanceMapper`,与同类 `sourceMapper` 直连模式一致);
2. **项目预付**:遍历核销行,`paymentApplicationMapper.selectBatchIds` 批量取底层付款申请回填已付金额/状态;
3. 合并后按"付款申请创建时间"降序(列表默认排序规范)。
尾款记录(`paymentApplications`)查询不动。
### 4.7 列表页 N+1 规避(D8/评审#5)
`selectPage` L209-210 逐行 `toVO`,而 `toVO`(L1827-1837)内部 `calculatePaymentSummary` 已是每行 3-4 次查询,**不能再叠加每行一次核销求和**:
- 新增重载 `toVO(FormalSettlement entity, Map<Long, BigDecimal> writeOffAmounts)`,把 map 置入 `PaymentSummaryData`;单参版委托 `toVO(entity, null)`(按需查询,`detail` 等单笔场景不受影响);
- `selectPage`:本页 `payable` 结算单 ids 一次 `sumBySettlementIds` 得 map,`result.convert(item -> this.toVO(item, writeOffMap))`。
### 4.8 结算调整单兜底(D7)
**`SettlementAdjustmentServiceImpl`**(L384-388 直改金额处):
1. 落库前校验:`projectAdvanceWriteOffService.sumBySettlement("formal", formal.getId())` ≤ 新 `settlementAmount`,超出抛"核销合计超过调整后结算金额,请先调减项目预付核销";
2. 落库后:`formalSettlementService.refreshPaymentSummary(formal.getId())`(注入 `IFormalSettlementService`,当前仅有 `formalMapper`)。
### 4.9 后端改动文件清单
| 文件 | 改动 |
|---|---|
| `service/impl/FormalSettlementServiceImpl.java` | `calculatePaymentSummary` 计入核销;`PaymentSummaryData` 加组件;`saveDraft` 补刷新 + 转移来源 PRE 行;`cloneFormalSettlement` 克隆/迁移核销行;`voidBill`/`voidByKingdee` 删行;`detail` 补 `advancePayments` + 新私有 `listAdvancePayments`;`toVO` 加 map 重载;`selectPage` 批量供数;注入 `PreSettlementAdvanceMapper` |
| `service/ISettlementProjectAdvanceWriteOffService.java` + `impl` | 新增 `deleteBySettlementIds`、`deleteByPaymentApplicationIds`、`sumBySettlementIds`;`listOptions`/`replaceWriteOffs` 加 `kingdeeStatus='paid'` 条件 |
| `mapper/SettlementProjectAdvanceWriteOffMapper.java` + `.xml` | 新增 `sumWriteOffBySettlementIds`(接口 + XML 成对) |
| `service/impl/PaymentApplicationServiceImpl.java` | `voidBill` 联动删核销行 + 刷新汇总 |
| `service/impl/PreSettlementServiceImpl.java` | `voidBill` 删 PRE 核销行 |
| `service/impl/SettlementAdjustmentServiceImpl.java` | 调整落库前核销合计校验 + 落库后刷新汇总;注入 `IFormalSettlementService` |
| `pojo/vo/SettlementAdvancePaymentVO.java`(新增) | 统一预付付款记录 VO |
| `pojo/vo/FormalSettlementVO.java` | 加 `advancePayments` |
## 五、前端改动(tms-erp-web)
### 5.1 结算合计汇总栏
**`formal-settlement-editor.vue`**:结算合计 section-card(L99-180)表格下方加汇总条(仅 `form.settlementType === 'payable'` 显示):
```
结算合计(原金额):form.settlementAmount
已付金额(含预付、核销):见下
剩余未付金额:max(结算合计 − 已付, 0)
```
- 编辑态:`Σ sources.advancePaidAmount + Σ projectAdvanceWriteOffs.writeOffAmount`(选预结算 `confirmCandidates` 与勾选核销行时实时联动;草稿态无尾款申请,公式完备;D2 下核销行必对应已付预付,计入实付口径成立);
- 只读态:`form.paidAmount`(已含尾款 + 进度预付 + 核销的落库值)。
### 5.2 付款记录合并表
只读"付款信息"应付表格(L580-612)数据源改为合并计算属性:
```js
paymentRecords() {
const advance = (this.advancePayments || []).map(r => ({ ...r, typeName: this.recordTypeName(r.recordType) }));
const tail = (this.paymentApplications || []).map(r => ({ ...r, typeName: r.paymentTypeName }));
return [...advance, ...tail];
}
// recordTypeName: progress_advance→'进度预付',project_advance→'项目预付核销'
```
- 列:付款类型(`typeName`,尾款行兼容原 `paymentTypeName`)/ 单据号 / **申请/核销金额**(预付行的"申请金额"实为核销金额,列名合并以免误导)/ 已付款金额 / 单据状态 / 金蝶单据号 / 创建人 / 创建时间(= 付款申请创建时间,见 4.6)。原"付款方式"列预付行无值,显示 `-`;
- 单据状态名映射**提取公共工具**(与 `pre-settlement-editor.vue` L2879 `advanceStatusName` 去重):新建 `src/utils/settlementStatus.js` 导出 `settlementStatusName(value)`(reviewing 审核中、approved 已审批、paid 已付款、returned 已驳回、voided 已作废、draft 草稿),两个编辑器共用,避免状态名不一致;
- `loadFormalDetail`(L1360-1379)回填 `this.advancePayments = data.advancePayments || []`,`readonly` 重置时清空。
### 5.3 转正式预填核销行
`confirmCandidates`(L1729-1752)选定来源预结算后:对每个新选 `preSettlementId` 调用既有 `preSettlement` detail 接口,取 `projectAdvanceWriteOffs` 预填进本单核销组件(与已有行按 `paymentApplicationId` 去重、金额 Number 化);保存时随 payload 提交,后端按 4.3 转移来源行。
### 5.4 前端不动的部分
- `project-advance-write-off.vue`:选单过滤、本次核销金额校验链路已完整(L198-288),"已付款"过滤条件由后端 `listOptions` 落实,组件不改;
- 列表页 `formal-settlement.vue`:"申请付款金额(含预付)/已收已付合计(含预付)/剩余可付金额"列直接受益于后端口径修正,不改;
- 尾款申请默认可付金额 `payableOfFormalSettlement` 已扣核销额(`PaymentApplicationServiceImpl` L2115-2122),不改。
## 六、边界场景
| 场景 | 行为 |
|---|---|
| 草稿保存核销 | 已付/已申请/剩余可付/付款状态立即生效(D2,行必对应已付预付),列表可见 |
| 底层项目预付申请作废 | 联动删核销行 + 批量刷新 → 已付回落(4.4) |
| 底层预付金蝶推送 `failed` / 反审核解锁 | 不删行,核销继续计入(预付申请仍 approved);人工作废后回落 |
| 金蝶付款完成回写(进度预付) | `syncOnePreSettlementAdvance` 刷新来源快照 → `refreshPaymentSummariesForPreSettlement` → 批量版自动计入核销(4.1 供数) |
| 正式结算单作废 / 金蝶删单回调 | 删除本单核销行(4.4),额度释放、详情无残留 |
| 预结算单作废 | 删除 PRE 核销行(4.4) |
| 预结算转正式(尾款结算) | 前端预填 + 后端先删来源 PRE 行再落 FORMAL 行(4.3),额度单份占用 |
| 轻量标记转正式(无在途预付) | PRE 核销行原地保留,待建正式单时转移(4.3) |
| 结算调整重推 | 核销行迁移到新单,原单删行并以新单为准(4.3) |
| 结算调整单调低金额致核销超限 | 落库前拦截,提示先调减核销(4.8) |
| 全额核销后发起调整 | `isSettlementCompleted` 拦截(现状逻辑),业务含义=已结清(D8 待确认) |
| 对账单生成 | 复制的 `paidAmount` 含核销口径(D8 待确认) |
| 核销合计 + 尾款申请超结算金额 | 双向封顶:核销合计 ≤ 结算金额(`replaceWriteOffs` L184-186);尾款默认额已扣核销额;`remainingPayableAmount` min 0 |
| 开票 | `settlementAmount` 全额不变,发票匹配上限(≤结算金额)不受影响(D1) |
| 应收(receivable)结算单 | 走收款认领分支,payable 分支的新增逻辑不触达 |
## 七、测试要点
1. `saveDraft` 带核销行 → `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus` 正确;编辑重复保存不重复累计(全删重插)。
2. D2:`approved` 未 `paid` 的项目预付不出现在选单(`listOptions`),手工提交被 `replaceWriteOffs` 拒绝。
3. 底层预付作废 → 核销行删除、汇总回落、列表金额一致;作废的正式结算单不再出现在批量刷新的已付统计中。
4. 预结算转正式:预填行保存成功(先删 PRE 行再校验余额的顺序);同一预付在 PRE/FORMAL 侧无双份核销。
5. 调整重推:新单含核销行且汇总正确(头字段从置零被重算纠正),原单行删除、汇总归零。
6. 结算调整单:调低金额低于核销合计被拒;合法调整后 `refreshPaymentSummary` 生效。
7. `detail` 返回 `advancePayments`:进度预付行(排除 voided)+ 项目预付行(回填底层实付),按付款申请创建时间倒序。
8. 列表页 SQL 次数:本页核销求和恒定 1 次(不随行数增长)。
9. 应收结算单、无预付结算单回归:汇总与现状一致。
10. 前端:编辑态汇总栏随选预结算/核销行实时变化;只读态合并付款记录表三类型展示、状态名与预结算页一致;应收侧页面不出现汇总栏。
## 八、范围外(明确不做)
- 预结算(PRE)侧自身收付汇总计入核销(本次仅 FORMAL);
- 金蝶预付抵扣字段 `y_billtype / y_billid / y_settleamt`(文档 TODO,本期不传);
- 应收侧预收/收款认领汇总;
- 存量"approved 未 paid 核销行"的数据修正脚本(如需另出);
- 遗留接口 `/pre-settlement/apply-advance` 等下线评估(见《2026-10-18-预付完整流程梳理》第五节)。
## 九、修订记录
| 版本 | 日期 | 内容 |
|---|---|---|
| v1 | 2026-10-18 | 初版:汇总计入、作废联动(仅付款申请侧)、detail 预付记录、前后端展示。 |
| v2 | 2026-10-18 | 评审修订:D2 改为"仅已付款可核销"(待确认);新增 D5 核销行流转(预结算转正式预填+转移、调整重推迁移)、D6 作废删行补全(结算单作废/金蝶回调/预结算作废)、D7 调整单校验+刷新、D8 对账与调整完成判定影响面(待确认);列表页 N+1 规避;文件清单补 Mapper 接口;合并表列名"申请/核销金额";状态名公共工具;createTime 统一为付款申请创建时间;点明 saveDraft 双刷新为有意设计。 |
@@ -0,0 +1,129 @@
# 改造后全链路逻辑梳理(预付 × 正式结算单)
> 关联文档:同目录《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》(改造前现状梳理)、同目录《2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md》(v2 设计与决策)
> 梳理日期:2026-10-18。本文描述**改造落地后**的完整逻辑,前端到后端各环节。
## 一、全景
```
【线A 进度预付】付款申请(progress_advance) ─A103-3→ 金蝶付款 ─回写→ 预结算预付汇总
└→ 尾款结算建正式单:继承预付快照 → 结算合计(全额)+汇总栏(已付含预付) → 审批 ─A102-2→ 财务应付单
└→ 尾款付款(默认额已扣核销) ─A103-1→ 回写paid → 付款记录合并表
【线B 项目预付】付款申请(project_advance) ─A103-3→ 金蝶付款 ─回写→ kingdeeStatus=paid
└→ 正式结算单「核销」(仅paid可选) → 核销计入已付 → 审批 ─A102-2→ 财务应付单(全额)
└→ 尾款付剩余 → 后续与正式结算单流程完全一致
```
**核心口径**(`FormalSettlementServiceImpl.calculatePaymentSummary` payable 分支,全系统唯一):
```
结算金额 settlementAmount = Σ 结算合计费用项(全额,不扣预付/核销——开票与金蝶应付基线)
已申请 appliedPaymentAmount = 尾款reviewing(直挂+关系表) + Σ来源预付快照 + Σ核销
已付 paidAmount = 尾款approved实付 + Σ来源预付快照 + Σ核销
剩余可付 remainingPayableAmount = max(结算金额 − 已付, 0)
付款状态 paymentStatus = unpaid / partial / paid
```
D2 决策:仅**金蝶已付款**(`kingdeeStatus='paid'`)的项目预付可核销,核销额计入已付属实付口径。
---
## 二、线A:进度预付(挂预结算单)
### A1 预付与付款(改造前已有)
- 前端 `pre-settlement.vue`「预付申请」→ `payment-application-form?paymentType=progress_advance`
- 后端 `PaymentApplicationServiceImpl`:审批 → AFTER_COMMIT `KingdeeApprovalSyncListener` → A103-3 直推金蝶 → 提前建档 `PreSettlementAdvance`(advanceNo=FKD 单号)
- 金蝶付款后 `PaymentKingdeeResultTask`(A103-2/A103-4)回写 paid
- 联动:`syncOnePreSettlementAdvance` 刷新预结算预付汇总(`advanceAppliedAmount/advancePaidAmount`)→ 同步刷新引用它的 `FormalSettlementSource` 快照 → `refreshPaymentSummariesForPreSettlement`(正式单已付跟着涨)
### A2 尾款结算 → 正式结算单表单(前端)
- `applyInitialSources`(列表跳转带入)/ `confirmCandidates`(弹窗追加):拉每张预结算 detail,合并明细与费用项 → `summaryTotal` → `form.settlementAmount`(**全额费用合计,不扣预付**)
- **汇总栏(新增)**:仅应付显示;编辑态实时 `已付 = Σ来源预结算.advancePaidAmount + Σ核销行`,`剩余 = 原金额 − 已付`;只读态 `已付 = form.paidAmount`(落库值,含尾款)
### A3 保存 `saveDraft`(后端,同一事务)
1. 来源合并(同合同同币种校验)→ `FormalSettlementSource` 快照(含预付汇总)
2. `refreshSettlementAmount`:settlementAmount=Σ费用项 → 内部刷一次汇总
3. **新增**:`deleteBySettlementIds(PRE, 来源预结算ids)`——先转移来源预结算的项目预付核销行(释放额度)
4. `replaceWriteOffs`(服务端复校验:paid、同合同、≤可核销余额、合计≤结算金额)→ **新增** `refreshPaymentSummary`(核销落库后必须再刷一次,第 2 步的刷新覆盖不到核销行)
### A4 审批 → 同步金蝶
- `approve` → `KingdeeApprovalSyncListener` → **A102-2 财务应付单,金额=全额结算金额** → lockBusiData 锁单 + A902 附件
### A5 只读详情(付款记录关联显示,本次核心新增)
- 后端 `detail` 新增 `advancePayments`:进度预付行 = 来源预结算的 `PreSettlementAdvance` 台账(排除 voided;appliedAmount=申请预付、paidAmount=实付;createTime 统一取**付款申请创建时间**,按 advanceNo 反查)
- 前端 `paymentRecords` 合并表:**进度预付 / 项目预付核销 / 结算尾款** 三类型一张表;状态名走公共 `src/utils/settlementStatus.js`
### A6 尾款付款
- 前端 `formal-settlement.vue`「付款申请」→ `referenceAmount` 默认可付 = **结算金额 − 累计尾款申请 − 核销**
- `settlement_payment` → 前置校验已有财务应付单 → A103-1 应付下推 → 回写 paid → `FormalSettlementPayment(final)` + 汇总刷新 → `paymentStatus=paid`
---
## 三、线B:项目预付(挂项目+承运商合同)
| 环节 | 逻辑 |
|---|---|
| B1 申请 | 付款申请 `project_advance`:选项目+承运商合同、不挂结算单;`validateQuota` 校验项目额度(fundLimit+临时额度)与客户额度(maxCreditLimit) |
| B2 付款 | 审批 → A103-3 直推 → 金蝶付款 → A103-2 回写 **`kingdeeStatus='paid'`**(核销前置条件) |
| B3 核销·选单(编辑器) | 全新单:核销组件「选择预付单」→ `project-advance-write-off-options`(后端过滤:同合同 + `approved` + **`paid`** + 有余额);**预结算转正式入口(新增)**:`confirmCandidates`/`applyInitialSources` 选定来源后自动预填来源预结算已保存的核销行,可增删改 |
| B3 核销·录额(前端) | 手录本次核销金额:>0、≤可核销余额、合计≤结算金额(`project-advance-write-off.vue` 既有校验链) |
| B4 保存 | 同 A3 第 3-4 步:先转移源 PRE 行 → `replaceWriteOffs` 复校验落 FORMAL 行 → 刷新汇总,核销计入已付 |
| B5 审批 | A102-2 按全额结算金额生成财务应付单(金蝶预付抵扣字段 `y_billtype/y_billid/y_settleamt` 本期不传) |
| B6 付款记录 | 合并表显示项目预付核销行:`申请/核销金额`=本次核销额,`已付款金额`=底层付款申请实付(paid 前置下两者相等,同屏一致) |
| B7 尾款 | 默认可付已扣核销 → 已核销部分不会被重复支付;剩余走正常尾款流程 |
| B8 差异 | 后续金额差异按既有规则走**结算调整单**(见横切 5) |
---
## 四、横切环节
| # | 环节 | 逻辑 |
|---|---|---|
| 1 | 列表页 | `selectPage` 本页 payable 一次 `sumWriteOffBySettlementIds`(group-by)→ `toVO(map)` 重算 → 列「申请付款金额(含预付)/已收已付合计(含预付)/剩余可付/收付款状态」直接反映核销;SQL 不随行数增长 |
| 2 | 汇总刷新触发点 | 保存、审批、金蝶付款回写、预结算预付快照联动、作废、调整——全部收敛到 `calculatePaymentSummary`,口径唯一 |
| 3 | 作废联动(新增) | 付款申请作废 → 删其核销行 + 刷新受影响正式单汇总(已付回落);正式单作废(`voidBill`)与金蝶删单回调(`voidByKingdee`)→ 删本单核销行(额度释放);预结算作废 → 删 PRE 核销行 |
| 4 | 核销行流转(新增) | 预结算转正式 = 先删源 PRE 行再落 FORMAL 行(同事务,同一预付的核销全链路只存在一份);调整重推 = 核销行克隆到新单 + 删原单行 + `refreshPaymentSummaries(两单)`(原单为调整存档,收付口径以新单为准) |
| 5 | 结算调整单(新增兜底) | 调整落库前校验**核销合计 ≤ 新结算金额**(超出拒绝并提示先调减核销);落库后 `refreshPaymentSummary` 重算 |
| 6 | 对账单 | `copyHeader` 复制 `paidAmount` → 对账单已付口径含核销(已确认接受) |
| 7 | 全额核销=结清 | `paymentStatus=paid` → `isSettlementCompleted` 拦截,不允许再发起结算调整(已确认接受) |
| 8 | 开票 | 始终按**全额 settlementAmount** 匹配(Σ匹配 ≤ 结算金额),预付/核销不影响发票 |
| 9 | 金蝶预付抵扣 | `y_billtype / y_billid / y_settleamt` 本期不传(见《2026-10-18-预付完整流程梳理》TODO) |
| 10 | 历史数据 | 本次不考虑存量"approved 未 paid"核销行的清洗 |
---
## 五、改动文件索引
**后端(tms-api)**
| 文件 | 内容 |
|---|---|
| `FormalSettlementServiceImpl` | 汇总计入核销(单笔/批量/toVO 重载)、saveDraft 转移+刷新、cloneFormalSettlement 迁移、voidBill/voidByKingdee 删行、detail.advancePayments、selectPage 批量供数 |
| `SettlementProjectAdvanceWriteOffServiceImpl` | listOptions/replaceWriteOffs 加 paid 条件;sumBySettlementIds / deleteBySettlementIds / deleteByPaymentApplicationIds |
| `SettlementProjectAdvanceWriteOffMapper.java/.xml` | sumWriteOffBySettlementIds |
| `PaymentApplicationServiceImpl` | voidBill 联动删核销行+刷新 |
| `PreSettlementServiceImpl` | voidBill 删 PRE 核销行 |
| `SettlementAdjustmentServiceImpl` | 调整前核销合计校验 + 调整后刷新汇总 |
| `SettlementAdvancePaymentVO`(新)/ `FormalSettlementVO` | 统一预付付款记录 VO / advancePayments 字段 |
**前端(tms-erp-web)**
| 文件 | 内容 |
|---|---|
| `formal-settlement-editor.vue` | 结算合计汇总栏;付款记录合并表;confirmCandidates/applyInitialSources 预填核销行 |
| `src/utils/settlementStatus.js`(新) | 状态名/记录类型名公共映射(预结算编辑器同步使用) |
---
## 六、已知口径与风险
1. 尾款默认可付额(`payableOfFormalSettlement`)= 结算金额 − 累计尾款申请 − 核销,**不扣进度预付已付部分**(改造前既有口径,本次未动);进度预付占大头时默认额可能偏大,提交后由审批环节把关。
2. 正式结算单 `paidAmount` 现含核销,下游消费该字段的报表/对账口径随之变化(对账已确认接受)。
3. 金蝶侧预付抵扣字段未传,金蝶报表与本系统"已付含核销"存在时间差。
@@ -0,0 +1,160 @@
# 预付完整流程梳理
> 范围:预付(进度预付 / 项目预付)→ 正式结算单 → 付款 → 金蝶回写等后续流程,前端到后端全链路。
> 依据代码现状梳理(tms-api / tms-erp-web),梳理日期:2026-10-18。
## 一、总览
预付有**两条线**,最终在金蝶(星瀚 BFI)汇合:
```
【进度预付线】 【项目预付线】
预结算单(审批通过,应付) 付款申请-项目预付(挂项目/合同,不挂结算单)
└─「预付申请」→ 付款申请(progress_advance) └─ 校验项目额度/客户额度 → A103-3 → 金蝶付款
└─ 审批 → A103-3 直推金蝶付款申请单 └─ 事后在预结算/正式结算单上「项目预付核销」
└─ 提前建档 PreSettlementAdvance(approved)
└─ 定时回写 A103-2/A103-4 → paid
└─ 预付记录置 paid + 预结算单预付汇总刷新
└─「尾款结算」→ 正式结算单(继承预付汇总) → 审批 → A102-2 财务应付单
└─「申请付款」→ 付款申请(settlement_payment) → A103-1 应付下推
└─ 回写 paid → FormalSettlementPayment + 结算单付款汇总(unpaid→partial→paid)
```
### 金蝶接口速查
| 接口 | 用途 | 触发方 |
|---|---|---|
| A103-3 | 预付款直接保存金蝶付款申请单(不绑应付单) | 付款申请(progress_advance / project_advance)同步金蝶 |
| A103-1 | 应付下推生成金蝶付款申请单 | 付款申请(settlement_payment)同步金蝶 |
| A103-2 | 付款申请单查询(幂等预检 + 结果回写) | 推送前预检、定时回写任务 |
| A103-4 | 付款处理查询(回填实付款记录) | 定时回写任务 |
| A102-2 | 财务应付单保存 | 正式结算单「同步金蝶」 |
| A102-4 | 财务应付单查询(防重预检 / 反查) | 推送前预检、下推反查付款计划分录 |
| A902 | 附件上传 | 主单推送成功后联带上传 |
| lockBusiData | 金蝶单据锁定 | 推送成功事务提交后 |
## 二、涉及的主要代码与数据表
### 后端(tms-api / blade-transport)
| 内容 | 位置 |
|---|---|
| 预结算单 | `controller/PreSettlementController.java`、`service/impl/PreSettlementServiceImpl.java` |
| 付款申请 | `controller/PaymentApplicationController.java`、`service/impl/PaymentApplicationServiceImpl.java` |
| 正式结算单 | `controller/FormalSettlementController.java`、`service/impl/FormalSettlementServiceImpl.java` |
| 项目预付核销 | `service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java` |
| 金蝶同步支撑 | `support/KingdeeApBillSyncSupport.java`(A102-2/A102-4)、`service/impl/KingdeeBillLockServiceImpl.java`(锁单)、`KingdeeAttachmentSyncServiceImpl.java`(附件) |
| 审批后自动推送 | `listener/KingdeeApprovalSyncListener.java`(AFTER_COMMIT)+ `event/KingdeeBillApprovedEvent.java` |
| 定时任务 | `task/PaymentKingdeeResultTask.java`(付款结果回写,每 10 分钟)、`task/KingdeeAutoSyncRetryTask.java`(失败重推,每 10 分钟)、`task/KingdeeBillLockRetryTask.java`(补锁) |
### 前端(tms-erp-web)
| 页面 | 位置 |
|---|---|
| 预结算单列表/表单 | `src/views/settlement/pre-settlement.vue`、`pre-settlement-form.vue`、`components/pre-settlement-editor.vue` |
| 正式结算单列表/表单 | `src/views/settlement/formal-settlement.vue`、`formal-settlement-form.vue`、`components/formal-settlement-editor.vue` |
| 项目预付核销组件 | `src/views/settlement/components/project-advance-write-off.vue` |
| 付款申请列表/表单 | `src/views/payment/payment-application.vue`、`payment-application-form.vue` |
| API 层 | `src/api/settlement/preSettlement.js`、`src/api/settlement/formalSettlement.js`、`src/api/payment/paymentApplication.js` |
### 核心数据表(实体)
| 表/实体 | 作用 |
|---|---|
| `PreSettlement`(预结算单) | 含预付汇总字段 `advanceNo / advanceAppliedAmount / advancePaidAmount`、`formalSettlementNo` |
| `PreSettlementAdvance`(预结算预付记录) | 预付台账:`advanceNo`(=付款申请单号)、`appliedAmount / paidAmount / billStatus(reviewing/approved/paid/voided) / kingdeeAdvanceNo` |
| `PaymentApplication`(付款申请) | `paymentType`:`progress_advance` 进度预付 / `project_advance` 项目预付 / `settlement_payment` 尾款付款;`kingdeeStatus`:`unsynced/synced/paying/paid/closed/failed` |
| `PaymentApplicationSettlement` | 付款申请 ↔ 结算单(正式/预结算)多选关系及分摊金额 |
| `PaymentApplicationRecord` | 付款记录(金蝶 A103-4 回填,`voucherJson` 留档凭证号/银行流水/电子回单) |
| `FormalSettlement`(正式结算单) | 含 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus / kingdeeSyncStatus` |
| `FormalSettlementSource` | 正式结算单来源预结算单/明细快照,冗余预付汇总 |
| `FormalSettlementPayment` | 正式结算单付款记录(尾款 `paymentType=final`) |
| `SettlementProjectAdvanceWriteOff` | 项目预付核销记录(`settlementType`:PRE/FORMAL) |
## 三、阶段详解
### 阶段一:前置 —— 预结算单审批通过
- 前端:预结算列表新建/编辑 → 保存草稿 → 提交 → 审批通过(`pre-settlement.vue`,API `/blade-transport/pre-settlement/save|submit|approve`)。
- 后端:`PreSettlementController` → `PreSettlementServiceImpl.saveDraft / submit / approve`。
- 审批通过(`approved`)后,**应付(payable)**预结算单即可发起预付。
### 阶段二:发起预付(进度预付)
1. **前端入口**:预结算列表勾选 →「预付申请」按钮。前端校验:同一合同、`approved`、`payable`、未转正式结算(`pre-settlement.vue` `handleAdvanceApplication`)。
2. **跳转付款申请表单** `/payment/payment-application/form?paymentType=progress_advance&preSettlementIds=...`(并非直接建预付单)。
3. **表单**(`payment-application-form.vue`):
- 进度预付仅可关联预结算单;不展示发票信息与付款记录;
- 可付金额实时调 `/payment-application/reference-amount` = 结算金额 − 累计已申请 − 项目预付核销额。
4. **后端**(`PaymentApplicationServiceImpl`):
- `saveDraft`:生成 `FKD+日期+序号` 单号;`fillReference` 回填预结算关联;保存 `PaymentApplicationSettlement` 关系表;
- `submit`:`draft/returned → reviewing`(财务审核);
- `approve`:`→ approved`,刷新关联结算单付款汇总,**发布 `KingdeeBillApprovedEvent`**。
> 说明:`PreSettlementController` 上的 `/apply-advance`、`/update-advance-paid`、`/void-advance` 为遗留接口,前端已不再调用;`PreSettlementAdvance` 台账现由付款申请流程联动维护(见阶段三/四)。
### 阶段三:同步金蝶(A103-3 预付款直推)
- 审批事务 **AFTER_COMMIT** → `KingdeeApprovalSyncListener.pushPayment`(Nacos 开关 `thirdParty.bfi.autoSync.payment`)→ `paymentApplicationService.syncKingdee`。
- `doSyncKingdee`:预付款走 **A103-3 直接保存金蝶付款申请单(不绑应付单)**;推送前 A103-2 幂等预检防重复;成功后落 `kingdeeStatus=synced` + `kingdeeBillId` → **lockBusiData 锁单** → A902 附件上传。
- **提前建档**:`syncPreSettlementAdvance(entity, approved)` —— 按付款申请号在 `PreSettlementAdvance` 生成预付记录(`advanceNo = FKD 单号`),刷新预结算单预付汇总(`advanceNo / advanceAppliedAmount / advancePaidAmount`),并联动刷新关联正式结算单付款汇总(预付信息两边页面即时可见)。
- **失败兜底**:`markKingdeeSyncFailed` 落 `failed`;`KingdeeAutoSyncRetryTask` 每 10 分钟自动重推;页面「同步金蝶」按钮手动兜底。审批接口通过 `pollSyncFailure()` 将推送失败原因以警告文案带回前端(审批结果不受推送影响)。
### 阶段四:金蝶付款 + 结果回写(付款之后)
定时任务 `PaymentKingdeeResultTask`(每 10 分钟,分布式锁 single-flight)→ `syncKingdeePaymentResult`:
1. **A103-2 付款申请单查询**:按 `paymentNo`(防重键)分批查询,回填金蝶单号、状态(`synced → paying → paid/closed`)、已付金额(分录 `e_paidamt` 汇总)。
2. **付款完成(paid)联动**:`syncPreSettlementAdvance(paid, 全额)` 置预付记录为已付。
3. **A103-4 付款处理查询**:回填付款记录 `PaymentApplicationRecord`——实付金额、付款日期、金蝶付款单号;凭证号/银行流水/电子回单整体存 `voucherJson` 留档追溯。
4. **异常自愈**:A103-2 连续两轮查不到已推送单据(Redis 计数)→ 判定金蝶侧已反审核删除 → `unlockBill` 置 `failed`、清空金蝶单号/内码、**联动作废未付的预付记录**(已付事实不回退);重推后重新建档。
5. 前端可手动触发:付款申请页「回写金蝶付款结果」按钮 `/sync-kingdee-result`。
### 阶段五:预结算 → 正式结算单
- **前端**:预结算列表「尾款结算」→ 校验同项目、`approved` → 跳 `/settlement/formal-settlement/form` 携带预结算数据(`handleFormalSettlement`)。
- **后端两条路**:
1. **完整建单**:`FormalSettlementServiceImpl.saveDraft` —— 合并多张预结算单(`FormalSettlementSource`)或应收应付明细;**继承预付汇总**(`advanceAppliedAmount / advancePaidAmount`);支持**项目预付核销** `replaceWriteOffs`(校验:同合同、审批通过、不超可核销余额、合计不超结算单金额);单号 `ZJS...`。
2. **轻量标记**:`PreSettlementController /formal-settlement` → `formalSettlement(id)` —— 预结算单回写 `formalSettlementNo`、源明细置 `formal_settled`;**前提:无在途预付申请**(存在 `reviewing/approved` 预付则拦截)。
- **正式结算单审批**:草稿 → 提交 → `approve` → 事件 → `syncKingdee` → **A102-2 财务应付单**(A102-4 预检/反查防重,一张结算单一张应付单)→ 回写 `kingdeeSyncStatus` → 锁单 + 附件。
- 列表展示「申请付款金额(含预付)/ 已收已付合计(含预付)」。
### 阶段六:尾款付款(正式结算单之后)
1. **前端**:正式结算单列表勾选(`approved`、`payable`、同合同)→「申请付款」→ 付款申请表单 `paymentType=settlement_payment`,默认取剩余可付金额(`openPaymentDialog`)。
2. **后端**:`saveDraft` 支持一单挂多张正式结算单(同合同,按可付金额比例分摊);`submit` 时 `validateNoTailPayment` 防重复尾款申请。
3. **同步金蝶路由不同**:结算付款走 **A103-1 应付下推**——前置校验每张正式结算单已在金蝶生成财务应付单(A102-4 预检,未生成则提示"请先在正式付款单页面同步金蝶"),再下推生成金蝶付款申请单;之后锁单、附件同前。
4. **回写后**:paid 时 `syncFormalSettlementPayments` 按关系表分摊生成 `FormalSettlementPayment(paymentType=final)` → `refreshPaymentSummary` 重算结算单 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus`(unpaid → partial → paid)。金额后续差异走**结算调整单**。
### 项目预付线(project_advance)
- 付款申请页「预付申请」按钮 → `project_advance`:选**项目 + 承运商合同**(仅承运商合同),不挂结算单、无发票/付款记录。
- `validateQuota` 校验:项目额度(`fundLimit` + 有效期内审批通过的临时额度)、客户额度(`maxCreditLimit`)。
- 审批 → A103-3 直推 → 金蝶付款 → 回写 paid。
- **后续核销**:预结算单/正式结算单表单中「项目预付核销」组件 → 保存时 `replaceWriteOffs` 写 `SettlementProjectAdvanceWriteOff`;可付金额计算统一扣减核销额(`referenceAmount`)。
- 金蝶侧预付抵扣字段(`y_billtype / y_billid / y_settleamt`)**TODO 本期不传**(`KingdeeApBillSyncSupport`,待业务确认后迭代)。
## 四、逆向流程
| 操作 | 规则 |
|---|---|
| 付款申请作废(仅 approved) | 金蝶锁单中禁止作废(`checkEditable`);汇票付款释放票据余额;**联动作废未付的预付记录**(已付事实不回退);刷新结算单汇总 |
| 预结算单作废 | 存在关联预付申请需先作废预付 |
| 预付记录作废 | 已付款的不能作废 |
| 金蝶反审核(解锁) | A103-2 连续两轮消失自动解锁置 `failed`,人工核实后重推;解锁时原金蝶单号/内码留档清空,重推生成新单后重新关联附件与预付记录 |
## 五、遗留接口说明
`PreSettlementController` 的以下接口前端已不再调用(前端统一走付款申请单流程),`PreSettlementAdvance` 表改由付款申请联动维护,保留兼容:
- `POST /pre-settlement/apply-advance`(发起预付申请)
- `POST /pre-settlement/update-advance-paid`(回写预付付款金额)
- `POST /pre-settlement/void-advance`(作废预付申请)
如需下线,需另行评估权限与存量数据兼容性。
## 六、风险与未覆盖范围
- 应收侧预收/收款认领(ReceiptClaim)流程未在本篇展开。
- 汇票付款(BillPayment)仅提及票据余额占用/释放联动。
- 金蝶 A102/A103 接口报文字段细节未逐项列出,参见 `KingdeeApBillSyncSupport`、`PaymentApplicationServiceImpl.pushBfiPayApply / pushBfiPayApplyPush` 源码注释。
@@ -0,0 +1,279 @@
# 预付转正式结算单与项目预付操作手册(纯操作·带状态)
> 整理日期:2026-10-18
> 定位:**给操作者和实施/培训用的纯操作手册**。只讲"在哪个页面、点哪个按钮、填什么、会看到什么状态",不含接口、表名、代码。
> 配套逻辑说明见同目录《2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md》。
---
## 一、先认状态(每个场景都对着这几张表看)
### 1.1 单据审批状态(所有单据通用)
```
新建/保存 ──→ 草稿 ──提交──→ 审批中 ──通过──→ 审批通过
↑ │
│ 驳回
└── 已驳回 ←────┘ (改完再提交)
草稿 / 审批中 / 审批通过 ──(可作废时)──→ 已作废(终态)
```
| 状态 | 能做什么 |
|---|---|
| 草稿 | 编辑 / 删除 / 提交 |
| 已驳回 | 编辑(保存后回到草稿)/ 提交 |
| 审批中 | 通过 / 驳回(审批人操作) |
| 审批通过 | 看后续:同步金蝶、付款申请、开票申请、作废(未锁定时) |
| 已作废 | 终态,只读 |
### 1.2 付款申请的金蝶状态
```
未同步 ──推送──→ 已同步 ──→ 付款中 ──→ 已付款
│ │
└────────── 生成失败(可重推) └──→ 已关闭
```
- **已付款**:钱已付出。**项目预付只有到"已付款"才能在结算单上核销**。
- **生成失败**:系统每 10 分钟自动重推;也可在列表勾选后点「批量同步」手动重推。
### 1.3 正式结算单上的四个状态
| 状态位 | 取值 | 看什么 |
|---|---|---|
| 审批状态 | 草稿 / 审批中 / 审批通过 / 已驳回 / 已作废 | 单据走到哪一步 |
| 金蝶同步状态 | 未同步 / 已同步 / 同步失败 | 有没有在金蝶生成应付单(应付)/应收单(应收) |
| 收付款状态 | 未收/付款 / 部分收/付款 / 已收/付款 | 收付进度(**已含预付、核销**) |
| 发票状态 | 应付=未收票/部分收票/已收票;应收=未开票/部分开票/已开票 | 票开/收到哪一步 |
### 1.4 金蝶锁定状态
| 显示 | 含义 | 影响 |
|---|---|---|
| 锁定中 | 正在向金蝶加锁 | 稍等 |
| 已锁定 | 金蝶已锁 | **不可编辑、不可作废** |
| 锁定失败·补偿中 / 已达上限 | 加锁未成功 | 定时自动补偿;必要时联系管理员 |
### 1.5 开票申请的金蝶状态
```
未同步 ──推送──→ 已同步 ──金蝶回写──→ 开票完成
│
└── 生成失败(可重推)
```
---
## 二、应付侧操作(钱付出去)
### 场景 1:预结算单直接发起「进度预付」
**前提**:预结算单是**应付**、状态**审批通过**、还没转正式结算。
1. 进「**预结算单**」列表,勾选预结算单(**同一合同**可多选)。
2. 点工具栏「**预付申请**」。
3. 自动跳到「付款申请」页面(类型已固定为进度预付):结算单只读,"结算金额/可付款金额"自动带出。
4. 填「申请付款金额」(不超过可付款金额)、「付款方式」→「保存」。
5. 回到「付款申请」列表 → 找到该单 →「**提交**」。
6. 审批人点「**通过**」。
**状态变化**:付款申请 `草稿 → 审批中 → 审批通过`;随后系统自动推金蝶,金蝶状态 `未同步 → 已同步 → 已付款`。
**同时发生**:预结算单上出现「申请预付金额 / 已预付金额」;预结算单仍然可以继续「尾款结算」。
> 若提交/通过后看到黄色警告写着金蝶推送失败:审批结果**不受影响**,稍后自动重推,或到付款申请列表点「批量同步」。
---
### 场景 2:发起「项目预付」(不挂结算单)
**前提**:项目已建、**承运商合同**已建;金额不超过项目额度与客户额度。
1. 进「**付款申请**」列表 → 点工具栏「**预付申请**」(注意:这个入口是从付款申请页进的,和场景 1 从预结算单进不同)。
2. 页面类型已固定为项目预付,填写:
- **所属项目**(必选)
- **合同名称**(必选,**只能选承运商合同**,选了项目后才能选)
- 申请付款金额、付款方式、付款方/收款方等
- 本类型不挂结算单,不显示发票信息与付款记录
3. 「保存」→ 列表「提交」→ 审批人「通过」。
**会被拦截的情况**:
- "申请付款金额不能超过项目额度"(项目额度 = 项目额度 + 有效期内的临时额度)
- "申请付款金额不能超过客户额度"
**状态变化**:同场景 1。**务必等到金蝶状态「已付款」**,否则下一步核销做不了。
---
### 场景 3:结算单上「项目预付核销」
**前提**:有一笔**已付款**的项目预付(金蝶状态=已付款),且与本次结算单**同一合同**,还有剩余可核销金额。
1. 打开结算单(**预结算单**或**正式结算单**都行)的编辑页。
2. 找到「**项目预付核销**」分区 → 点「**选择**」。
3. 弹窗「选择项目预付单」里勾选要抵扣的预付单(已选的会灰掉,不能重复选)→「确定」。
4. 在「本次核销金额」列:默认已按"可核销金额"填满,可以改小。
5. 「保存」。
**会被拦截的情况(提示文案)**:
- "预付单 XXX 本次核销金额必须大于 0"
- "本次核销金额不能超过可核销金额"
- "项目预付核销合计不能超过结算单金额"
- "仅可选择审核通过的项目预付单"
- "预付单 XXX 尚未付款,项目预付须金蝶付款完成后方可核销"
- "同一项目预付单不可重复核销"
- "项目预付单与结算单合同不一致"
**状态变化**:结算单「**已付金额(含预付、核销)**」立刻增大,「剩余未付金额」减小;列表上"申请付款金额(含预付)/已收已付合计(含预付)/剩余可付/收付款状态"同步变化。
---
### 场景 4:预结算单转「正式结算单」(容纳预付)
**前提**:预结算单**审批通过**,且**同一项目**。
1. 进「**预结算单**」列表,勾选(可多选,须同项目)→ 点工具栏「**尾款结算**」。
2. 系统跳到「正式结算单」新增页,把所选预结算单的明细、费用项合并进来。
3. 检查三处金额(**应付结算单才有**):
- **结算合计(原金额)**:费用合计,**始终全额**,不会因为预付/核销而变小;
- **已付金额(含预付、核销)**:把来源预结算的已付预付 + 本次核销都算进去;
- **剩余未付金额** = 结算合计 − 已付。
4. 「项目预付核销」区会**自动带出来源预结算上已核销的行**,可增/删/改金额。
5. 「保存」(草稿)→「提交」→ 审批人「通过」。
**状态变化**:正式结算单 `草稿 → 审批中 → 审批通过`;预结算单会被标记为已转正式结算(来源被锁定)。
**注意**:如果预结算单上还有**审批中或已审批但没结束的预付申请**,走"轻量转正式"会被拦住,需要先把预付处理完。
---
### 场景 5:正式结算单「同步金蝶」
**前提**:结算单**审批通过**。
1. 进「**正式结算单**」列表,切到「**应付**」页签。
2. 勾选 **1 条**(按钮只允许选一条)→ 点工具栏「**同步金蝶**」。
3. 成功后会提示"同步成功,金蝶单据号:XXX";列表「金蝶同步状态」变为**已同步**,「金蝶锁定状态」变为**已锁定/锁定中**。
**生成失败怎么办**:列表金蝶状态显示"同步失败",重新点「同步金蝶」即可。
**提示"该单附件未全部上传,可重推补传"**:单据已同步成功,附件稍后重推会自动补传。
> 审批通过时系统也会自动推一次;若失败,只弹黄色警告,审批照样通过,按上面手动补推。
---
### 场景 6:正式结算单「付款申请」(付尾款)
**前提**:正式结算单是**应付**、**审批通过**、**同一合同**、还有剩余可付金额。
1. 「正式结算单」列表(应付)→ 勾选 1 条或多条(同合同)→ 工具栏「**付款申请**」。
2. 弹窗里确认各单的「申请付款金额」(默认已填=剩余可申请金额,**已扣掉核销**)→「提交」。
3. 到「付款申请」列表「提交」→ 审批人「通过」。
**会被拦截的情况**:
- "仅审批通过的应付正式结算单允许发起付款申请"
- "付款申请必须选择同一个合同的正式结算单"
- 提示需要先在正式结算单页面同步金蝶(说明该单在金蝶还没有应付单,尾款是从应付单下推的,**必须先做场景 5**)
**状态变化**:正式结算单「收付款状态」`未付款 → 部分付款 → 已付款`,金额按付款结果自动重算。
**金额错了怎么办**:正式结算单**审批通过后不能直接改金额**,走「**结算调整单**」(见《2026-10-17-结算调整单操作流程说明(分场景).md》)。
---
## 三、应收侧操作(票开出去)
### 场景 7:应收正式结算单「同步金蝶」
1. 「正式结算单」列表切到「**应收**」页签 → 勾选 1 条 → 工具栏「**同步金蝶**」。
2. 列表「金蝶同步状态」变**已同步**,可见金蝶单据号。
**必须做这一步**:正式结算单开票要求先有金蝶应收单,没做这步,开票申请推不出去。
---
### 场景 8:创建并推送「开票申请」
**前提**:有一张**应收**正式结算单,**审批通过**、未作废、发票状态还是「**未开票**」,且已完成场景 7。
1. 进「**开票申请**」列表 → 点「**新增**」。
2. 「开票基本信息」里点「结算单」右侧的「**选择**」→ 弹窗「选择应收正式结算单」→ 搜索并勾选 →「确定」。
- 「项目」「开票单位」自动带出;「**可开票金额**」= 所选结算单剩余可开票额之和。
- 受票方开票信息按客商档案自动带出。
3. 填「发票类型」等;选**电子专票**时,纳税人识别号、开户行、开户账号、注册地址**必填**。
4. 填「开票内容」(发票张次 + 商品行),系统自动算「**本次开票金额**」。
5. 在「结算信息」表里给每张结算单填「**分摊开票金额**」(不超过该单剩余可开票金额)。
6. 「开票明细」里勾选明细 →「保存」→「提交」→ 审批人「通过」。
**会被拦截的情况**:
- "只能选择审批通过、未作废、未收票的应收正式结算单"
- "结算单 XXX 的分摊金额超过剩余可开票金额"
- "本次开票金额必须大于 0"
- "请选择受票方有效的开票信息"
- "未找到正式结算单受票方对应的客商档案"
**状态变化**
| 对象 | 状态变化 |
|---|---|
| 开票申请 | 审批 `草稿 → 审批中 → 审批通过`;金蝶 `未同步 → 已同步 → 开票完成` |
| 来源正式结算单 | 发票状态 `未开票 → 部分开票 → 已开票`(按分摊与回写自动算) |
**看发票结果**:开票申请详情页的发票信息里能看到发票号、开票日期、价税合计、发票文件。金蝶回写是定时的,没出来可以先等,或在开票申请列表点「**批量同步**」推一把。
**限制**:开票申请一旦推到金蝶(已同步/开票完成)就**锁定不能改**;数电票已经开出来的只能走红冲,不能在本系统作废。**预付和核销不影响开票金额**,开票始终按结算单的**全额**来匹配。
---
## 四、作废与撤销:能做什么、状态怎么变
| 我想…… | 前提 | 操作位置 | 状态变化 |
|---|---|---|---|
| 作废付款申请 | 状态=审批通过,且**未被金蝶锁定** | 付款申请列表「作废」 | 单据→已作废;**未付的预付记录联动作废,相关核销行被删除**,受影响结算单的已付金额回落(已付出去的钱不会回退) |
| 作废正式结算单 | 未审批通过,或审批通过但**未锁定** | 正式结算单列表「作废」 | 单据→已作废;本单的核销行被删除,可核销额度释放 |
| 作废预结算单 | 无关联预付申请(有则先作废预付) | 预结算单列表「作废」 | 单据→已作废;预结算上的核销行被删除 |
| 作废开票申请 | 状态=审批通过,且**未锁定**(即还没推金蝶/还没开票) | 开票申请列表「作废」 | 单据→已作废 |
| 删除 | 状态=草稿 | 各列表「删除」 | 草稿被删除 |
| 金蝶侧反审核删单 | — | 金蝶操作后由系统处理 | 付款申请金蝶状态→生成失败,金蝶单号被清空;**未付的预付记录联动作废**;人工核实后重推,重推成功会重新建档;**已核销的行不会自动删**,需要人工作废该预付款才回落 |
> 一句话记住:**预付一旦真付出去(已付款),作废不会把钱退回**,只会把台账和核销额度清掉。
---
## 五、操作者常见疑问
| 问题 | 答案 |
|---|---|
| 为什么选不到某张预结算单发起预付? | 需要**应付**、**审批通过**、**未转正式结算**、且所选单据**同一合同**。 |
| 为什么核销弹窗里找不到那笔项目预付? | 它必须已**金蝶付款完成(已付款)**,且与结算单**同一合同**、还有剩余可核销额。 |
| 为什么新增开票申请时选不到某张应收结算单? | 需要**审批通过、未作废、发票状态=未开票**;已被别的开票申请占用的会扣掉可开票额。 |
| 开票提示要先去同步金蝶? | 正式结算开票前,必须先在该应收结算单上点「同步金蝶」生成金蝶应收单。 |
| 付款申请审批通过了,但金蝶那边没有单? | 看金蝶状态:若"生成失败",点「批量同步」重推或等自动重推。 |
| 结算单已付金额比我算的多/少? | 已付金额**含**进度预付、项目预付核销;核销金额同时计入"已付"和"剩余可付"的计算,属正常口径。 |
| 结算单金额写错了怎么改? | 审批通过后不能直接改,走「结算调整单」。 |
| 单据上的「作废」是灰的? | 可能已被金蝶锁定,或状态不满足(只有审批通过且未锁定才能作废)。 |
---
## 六、一眼看懂整个流程(操作版)
```
【应付·进度预付】
预结算单(审批通过) →「预付申请」→ 付款申请(提交→通过) → 金蝶付款(已付款)
└ 预结算单出现"已预付金额"
【应付·项目预付】
付款申请页「预付申请」→ 选项目+承运商合同 → 提交→通过 → 金蝶付款(已付款)
【预结算 → 正式结算(容纳预付)】
预结算单(审批通过·同项目) →「尾款结算」→ 正式结算单
结算合计(全额) / 已付(含预付、核销) / 剩余未付
项目预付核销自动带入 → 保存 → 提交 → 通过
└ 「同步金蝶」(应付单已同步)
【应付·尾款】
正式结算单(应付·审批通过) →「付款申请」→ 付款申请 → 金蝶应付下推 → 已付款
└ 结算单收付款状态:未付款 → 部分付款 → 已付款
【应收·开票】
应收正式结算单(审批通过·未开票) →「同步金蝶」(应收单已同步)
→「开票申请」新增 → 选结算单 → 填开票内容与分摊 → 提交 → 通过
→ 金蝶开票(已同步 → 开票完成) → 结算单发票状态:未开票 → 部分开票 → 已开票
```
@@ -0,0 +1,363 @@
# 预付转正式结算单与项目预付操作流程说明(分场景·前端后端)
> 整理日期:2026-10-18
> 定位:面向操作者与实施/联调人员的**分场景操作手册 + 逻辑说明**。讲清「谁在哪个页面点什么 → 后端发生什么 → 数据表/金蝶变成什么 → 怎么校验成功」。
> 依据代码现状梳理(`tms-api` / `tms-erp-web`)。关联文档(同目录):
> - 《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》——改造前现状
> - 《2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md》——v2 设计与决策
> - 《2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md》——改造后全链路
> - 《2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md》——开票链
> - 《2026-09-29-预结算开票(预开票)金蝶推送方案.md》——预开票(**方案,尚未落地**)
---
## 〇、先看全局:两条预付线 + 一条开票线
### 0.1 术语
| 术语 | 含义 |
|---|---|
| 预结算单(PreSettlement) | 结算前的费用单据,审批通过后作为预付/尾款结算的依据。有 **应付(payable)/应收(receivable)** 两种。 |
| 进度预付(progress_advance) | 挂在**预结算单**上的预付,属**应付**侧(我方付出去)。 |
| 项目预付(project_advance) | 挂在**项目 + 承运商合同**上的预付,不挂结算单,属**应付**侧。 |
| 核销 | 项目预付金在后续结算单上"抵扣",落 `SettlementProjectAdvanceWriteOff` 行。**仅金蝶已付款(paid)的项目预付可核销**。 |
| 容纳 / 纳入 | 预结算单转正式结算单时,其**预付台账快照**(进度预付)与**核销行**(项目预付)被正式结算单继承,计入正式单的"已付/已申请/剩余可付"。 |
| 尾款 | 正式结算单扣除已付(含预付、核销)后的剩余部分,走单独付款申请(settlement_payment)。 |
| 开票 | **应收**侧动作:正式结算单 → 开票申请 → 金蝶开票单。**应付**侧对应的是**收票**,两者共用 `formal_settlement.invoice_status` 字段,名称按结算方向区分。 |
### 0.2 全景图
```
【应付线 · 预付】 【应收线 · 开票】
预结算单(应付) approved 预结算单(应收) approved
│ │
├─ 预付申请(进度预付 progress_advance) └─ 尾款结算
│ └ 审批 → A103-3 直推金蝶付款 → 回写 paid └→ 应收正式结算单
│ └ 提前建档 PreSettlementAdvance ├ 审批 → 同步金蝶 A302-2 财务应收单
│ └ 开票申请(选应收正式结算单)
项目预付(project_advance) ← 付款申请页「预付申请」 └ 审批 → A303-1 开票申请单
├ 选 项目 + 承运商合同(校验项目额度/客户额度) └ A303-5 回写发票 → 开票完成
├ 审批 → A103-3 → 金蝶付款 → 回写 paid
└ 后续在结算单上「项目预付核销」
▼ 双线在「正式结算单」汇合
预结算单(应付) ──「尾款结算」──→ 应付正式结算单(容纳预付快照 + 核销行)
├ 审批 → 同步金蝶 A102-2 财务应付单(全额 settlementAmount)
└「付款申请」→ 尾款付款 → A103-1 应付下推
```
### 0.3 金蝶接口速查
| 接口 | 用途 | 触发方 |
|---|---|---|
| A103-3 | 预付款直接保存金蝶付款申请单(不绑应付单) | 付款申请(progress_advance / project_advance)同步金蝶 |
| A103-1 | 应付下推生成金蝶付款申请单 | 付款申请(settlement_payment)同步金蝶 |
| A103-2 / A103-4 | 付款申请单查询 / 付款处理查询 | 推送前幂等预检、定时回写(每 10 分钟) |
| A102-2 / A102-4 | 财务应付单保存 / 查询(防重预检) | 应付正式结算单「同步金蝶」 |
| A302-2 / A302-4 | 财务应收单保存 / 查询(勾稽) | 应收正式结算单「同步金蝶」 |
| A303-1 / A303-4 / A303-5 | 开票申请单保存 / 幂等预检 / 开票结果查询 | 开票申请「同步金蝶」与定时回写 |
| lockBusiData | 金蝶单据锁定 | 推送成功事务提交后 |
| A902 | 附件上传 | 主单推送成功后联带 |
---
## 一、应付侧:预付转正式结算单(被"容纳")
### 场景 A1:发起进度预付(挂预结算单)
**前提**:预结算单 `approvalStatus=approved`、`settlementType=payable`、未转正式结算(`formalSettlementNo` 为空)。
**前端操作**
1. 菜单进「预结算单」列表 → 勾选 1 条或多条预结算单(**必须同一合同**)。
2. 点工具栏「**预付申请**」。
- 校验不通过时的提示:缺合同 ID / 多合同 / 非 payable / 非 approved / 已转正式。
3. 跳转「付款申请」表单,页面类型已锁定为**进度预付**:结算单只读、结算金额与可付款金额自动带出。
4. 填「申请付款金额」「付款方式」→「保存」→ 详情页「提交」→ 列表「通过」。
**后端动作**
| 步骤 | 接口/方法 | 结果 |
|---|---|---|
| 可付金额 | `GET /payment-application/reference-amount` | `可付 = 预结算金额 − 累计已申请 − 项目预付核销额` |
| 保存 | `POST /payment-application/save` → `saveDraft` | 生成 `FKD+日期+序号` 单号;写 `PaymentApplicationSettlement` 关系表 |
| 提交 | `POST /payment-application/submit` | `draft/returned → reviewing` |
| 审批 | `POST /payment-application/approve` | `→ approved`,刷新预结算付款汇总,发布 `KingdeeBillApprovedEvent` |
| 自动推送 | `KingdeeApprovalSyncListener`(AFTER_COMMIT,开关 `thirdParty.bfi.autoSync.payment`) | A103-3 直推金蝶付款申请单;**提前建档** `PreSettlementAdvance`(`advanceNo = FKD 单号`);刷新预结算预付汇总并联动正式结算单 |
> 说明:`PreSettlementController` 的 `/apply-advance`、`/update-advance-paid`、`/void-advance` 为**遗留接口**,前端已不再调用,台账改由付款申请流程联动维护。
**结果校验**:预结算单列表「申请预付金额 / 已预付金额」变化;付款申请 `kingdeeStatus` 从 `unsynced → synced`。
### 场景 A2:发起项目预付(挂项目 + 承运商合同)
**前提**:项目已建、承运商合同已建;不超过项目额度与客户额度。
**前端操作**
1. 菜单进「付款申请」列表 → 点工具栏「**预付申请**」(注意:与 A1 的入口不同,本入口从付款申请页进)。
2. 表单页类型为**项目预付**,填写:
- **所属项目**(必选,下拉)
- **合同名称**(必选,**仅承运商合同**,选项目后才可选)
- 申请付款金额、付款方式、付款方/收款方等
- 不挂结算单,不展示发票信息与付款记录
3. 「保存」→「提交」→「通过」。
**后端动作**
| 步骤 | 接口/方法 | 结果 |
|---|---|---|
| 保存 | `saveDraft` → `validateQuota` | 项目额度、客户额度双重校验(见下) |
| 提交/审批 | `submit` / `approve` | 同上,审批后发事件推 A103-3 |
| 推送 | A103-3 | 金蝶生成付款申请单,`kingdeeStatus=synced` |
| 回写 | `PaymentKingdeeResultTask`(A103-2/A103-4,每 10 分钟) | `synced → paying → paid`;回填实付金额与付款记录 |
**额度校验口径**(`PaymentApplicationServiceImpl.validateQuota`,悲观锁 `FOR UPDATE`)
```
项目额度 = (项目 fundLimit + Σ有效期内 approved 临时额度) × 10000
客户额度 = 客商 maxCreditLimit × 10000
申请付款金额 > 额度 → 分别报"申请付款金额不能超过项目额度 / 客户额度"
```
**结果校验**:付款申请列表 `kingdeeStatus=已付款(paid)`,金蝶单据号已回填。**只有 paid 才能进入后续核销**。
### 场景 A3:项目预付核销(在结算单上选单 + 录额)
**前提**:存在 `approvalStatus=approved` 且 `kingdeeStatus=paid` 的项目预付,与结算单**同一合同**,且有剩余可核销余额。
**前端操作**
1. 打开**预结算单编辑器**(或后续的**正式结算单编辑器**)→「**项目预付核销**」分区 → 点「**选择**」。
2. 弹窗「选择项目预付单」列出可核销单(同合同 + 已审批 + **已付款** + 有余额),已选行不可重复勾选。
3. 勾选「确定」→ 默认按"可核销金额"预填「本次核销金额」,可手工改小。
4. 「保存」(或保存草稿)。
**校验链(前端 → 后端一致)**
| 层级 | 规则 | 提示 |
|---|---|---|
| 前端 | 金额 > 0 | 预付单 X 本次核销金额必须大于 0 |
| 前端 | 金额 ≤ 可核销金额 | 本次核销金额不能超过可核销金额 |
| 前端 | 核销合计 ≤ 结算单金额 | 项目预付核销合计不能超过结算单金额 |
| 后端 | 合同一致 | 项目预付单与结算单合同不一致 |
| 后端 | 仅项目预付 | 仅可核销项目预付单 |
| 后端 | approved | 仅可选择审核通过的项目预付单 |
| 后端 | **paid**(本次新增) | 预付单 X 尚未付款,项目预付须金蝶付款完成后方可核销 |
| 后端 | 同一预付单不重复 | 同一项目预付单不可重复核销 |
| 后端 | 有余额 / 合计 ≤ 结算金额 | 超出可核销金额 / 核销合计不能超过结算单金额 |
**结果校验**:正式/预结算单「已付金额」立即包含核销额(核销行必对应已付款预付,计入实付口径),列表「申请付款金额(含预付)/已收已付合计(含预付)/剩余可付」同步变化。
### 场景 A4:预结算转正式结算单(容纳预付)
**前提**:预结算单 `approved`、**同一项目**;此时其预付(进度预付台账、项目预付核销行)将被正式单继承。
**前端操作(两条路径)**
1. 「预结算单」列表勾选 → 工具栏「**尾款结算**」→ 跳「正式结算单」表单,来源预结算明细与费用项自动合并。
2. 或在正式结算单表单里通过弹窗「追加来源预结算」。
**表单上的三处关键呈现(应付侧)**
- **结算合计(原金额)**:Σ 费用项 = `settlementAmount`,**始终全额,不扣预付/核销**(开票匹配上限、金蝶应付单金额基线)。
- **汇总栏**「已付金额(含预付、核销)/ 剩余未付金额」:
- 编辑态实时算:`已付 = Σ来源预结算.advancePaidAmount + Σ核销行.writeOffAmount`;
- 只读态取落库值 `form.paidAmount`(已含尾款 + 进度预付 + 核销)。
- **项目预付核销**:从预结算来源带入的核销行会**自动预填**,可增删改。
**后端动作(`FormalSettlementServiceImpl.saveDraft`,同一事务)**
1. 来源合并校验(同合同、同币种)→ 写 `FormalSettlementSource` 快照(含预付汇总 `advanceAppliedAmount / advancePaidAmount`)。
2. `refreshSettlementAmount`:以费用项重算 `settlementAmount`,并刷一次汇总。
3. **转移来源核销行**:`deleteBySettlementIds(PRE, 来源预结算ids)` —— 先释放来源占用,后续"不超可核销余额"校验才能通过预填金额。
4. `replaceWriteOffs`:服务端复校验后落 **FORMAL** 核销行。
5. `refreshPaymentSummary` 再刷一次(核销落库后必须再算,第 2 步覆盖不到核销行)。
> 原则:**同一笔预付的核销在链路上只存在一份** —— 预结算转正式 = 删源 PRE 行 + 落 FORMAL 行。
> 轻量标记路径(`PreSettlementController /formal-settlement`):仅回写正式结算单号,无正式单实体,PRE 核销行原地保留,待真正建单时再转移。**前提:无在途预付申请**(存在 reviewing/approved 预付会拦截)。
**结果校验**:正式结算单「申请付款金额(含预付)」「已收已付合计(含预付)」「剩余可付」「收付款状态」四列直接反映预付与核销;同一预付在 PRE / FORMAL 侧无双份核销。
### 场景 A5:正式结算单同步金蝶(A102-2 财务应付单)
**前端操作**:正式结算单列表 → **应付** tab → 勾选 1 条(按钮要求单选)→ 工具栏「**同步金蝶**」。
**后端动作**:`POST /formal-settlement/sync-kingdee` → 按结算方向路由:
- 应付 → **A102-2 财务应付单**(A102-4 幂等预检 / 反查,一张结算单一张应付单),金额 = **全额 settlementAmount**;
- 成功后回写 `kingdeeSyncStatus=synced` + 金蝶单据号/内码 → `lockBusiData` 锁单 → A902 附件上传。
**结果校验**:列表「金蝶同步状态」= 已同步、「金蝶锁定状态」= 已锁定 / 锁定中;附件未全部上传时页面追加提示"可重推补传"。
**审批时自动推送**:正式结算单「通过」后也会 AFTER_COMMIT 自动推;若推送失败,审批结果为 `false` + 具体原因以 warning 提示,审批本身不失败,走「同步金蝶」手动兜底或定时重推。
### 场景 A6:尾款付款(正式结算单之后)
**前提**:正式结算单 `approved`、`payable`、同一合同、有剩余可付金额。
**前端操作**:正式结算单(应付)列表勾选 1 条或多条 → 工具栏「**付款申请**」→ 弹窗确认各单申请金额(默认 = 剩余可付,已扣核销)→「提交」。
**后端动作**
1. `saveDraft`:可挂多张同合同正式结算单,按可付金额比例分摊;`submit` 时 `validateNoTailPayment` 防重复尾款申请。
2. **推送路由不同**:尾款走 **A103-1 应付下推**,前置对每张正式结算单做 A102-4 预检——**未在金蝶生成财务应付单则拦截**(提示先在正式结算单页同步金蝶);下推生成金蝶付款申请单,随后锁单 + 附件。
3. 回写 `paid` → 生成 `FormalSettlementPayment(paymentType=final)` → `refreshPaymentSummary` 重算 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus`(unpaid → partial → paid)。
**金额口径(全系统唯一定义)**
```
settlementAmount = Σ 结算合计费用项(全额,不扣预付/核销)
appliedPaymentAmount = 尾款申请中(reviewing) + Σ来源预结算 advanceAppliedAmount + Σ核销行
paidAmount = 尾款已付(approved 实付) + Σ来源预结算 advancePaidAmount + Σ核销行
remainingPayableAmount = max(settlementAmount − paidAmount, 0)
paymentStatus = unpaid / partial / paid
```
---
## 二、应收侧:正式结算单推送开票
> 开票是**应收**方向:给客户开票 → 金蝶开票单。与应付侧的"收票"共用 `invoice_status` 字段,文案按方向区分(未开票 / 部分开票 / 已开票,未收票 / 部分收票 / 已收票)。
### 场景 B1:应收正式结算单同步金蝶(A302-2 财务应收单)
**前端操作**:正式结算单列表切到「**应收**」tab → 勾选 1 条 → 工具栏「**同步金蝶**」。
**后端动作**:`POST /formal-settlement/sync-kingdee` → **A302-2 财务应收单**(防重键 `正式结算单号_结算明细单据号`)→ 回写单据号/内码 + `synced` → 锁单 + A902 附件。
另有定时勾稽 `syncKingdeeArResult`(A302-4):对已同步/生成失败的应收结算单批量反查金蝶应收单并纠偏回填,可手动触发 `POST /formal-settlement/sync-kingdee-ar-result`。
**结果校验**:应收正式结算单 `kingdeeSyncStatus=已同步`,列表可见金蝶单据号。
> **开票前置**:正式结算开票要求**先有财务应收单**。开票申请推送时按 A302-4 逐结算单预检,未生成则拦截并提示"请先在正式结算单页面同步金蝶"。
### 场景 B2:开票申请创建与推送(A303-1 / A303-5)
**前提**:存在 `approved`、未作废、**未收票/未开票(invoiceStatus=unreceived)** 的**应收**正式结算单。
**前端操作**
1. 菜单进「**开票申请**」列表 → 点「**新增**」。
2. 「开票基本信息」区点「结算单」右侧「**选择**」→ 弹窗「选择应收正式结算单」(可按结算单号、项目、合同搜索)→ 勾选 → 「确定」。
- 项目、开票单位(= 结算单收款方)自动带出;「**可开票金额**」= Σ 各结算单剩余可开票额;受票方开票信息按受票方客商档案带出。
3. 填「发票类型」(电子专票时纳税人识别号/开户行/开户账号/注册地址必填)、受票方、邮箱、「开票内容」(发票张次与商品行)→ 系统自动算「本次开票金额」= 行合计。
4. 「结算信息」表中填各结算单的**分摊开票金额**(≤ 该单剩余可开票金额)。
5. 「开票明细」勾选明细 → 「保存」(草稿)→ 「提交」→ 列表「通过」。
**后端动作**
| 步骤 | 接口/方法 | 规则 |
|---|---|---|
| 保存 | `POST /invoice-application/save` → `saveDraft` | ① 仅可选择**应收、审批通过、未作废、未收票**的正式结算单;② 结算单需兼容(同项目/开票方等);③ 分摊金额 ≤ 剩余可开票金额;④ 行合计 > 0;⑤ 受票方须有客商档案与有效开票信息;⑥ 电子专票四要素必填;⑦ 保存后刷新来源结算单开票状态,写变更记录 |
| 提交 | `/invoice-application/submit` | `draft/returned → reviewing`,写申请日期 |
| 审批 | `/invoice-application/approve` | `→ approved`,发布事件 → AFTER_COMMIT 推金蝶 |
| 推送 | `syncKingdee` → `doSyncKingdee` | ① **必须关联正式结算单**;② 逐结算单 A302-4 预检应收单已生成;③ A303-4 幂等预检(已生成的张次跳过并回填);④ 按发票张次拆分 `billno = 单号-张次` 逐张推 A303-1;⑤ 成功后锁单 + A902 附件 |
| 回写 | 定时 `syncKingdeeInvoiceResult`(A303-5,按 `serialNo=billno` 分批 ≤50)或手动 `POST /invoice-application/sync-kingdee-invoice-result` | 查到发票 → 落 `InvoiceApplicationInvoice` 发票记录并推进 `invoiced` 终态;同时刷新来源结算单 `invoice_status`(部分开票 → 已开票) |
**结果校验**:开票申请 `kingdeeStatus=已同步 / 开票完成`,详情页可见发票结果(发票号、开票日期、价税合计、文件);来源正式结算单「发票状态」从未开票 → 部分开票 → 已开票。
**边界与限制(现状)**
- 已推送(`synced/invoiced`)**锁单不可改**;仅审批通过且未锁定时可「作废」。
- 数电票已开出只能红冲(不走本系统作废)。
- **预付/核销不影响开票**:开票按**全额 `settlementAmount`** 匹配,预付与核销只影响应付侧的已付/剩余可付。
### 场景 B3:预开票(预结算单直接开票)——**方案,尚未落地**
按《2026-09-29-预结算开票(预开票)金蝶推送方案》,预结算(应收)可**只推 A303-1 开票申请单**、不校验应收单,新增 `invoice_source=pre` 与预结算关联表区分。
**当前代码状态**:`InvoiceApplicationServiceImpl.doSyncKingdee` 仍硬校验"必须关联正式结算单",创建入口也只允许选应收正式结算单 ⇒ **预开票目前推不出去**,需按上述方案改造后才能操作。本文不列为可执行场景。
---
## 三、状态机与字段口径
### 3.1 付款申请(PaymentApplication)
| 字段 | 取值 | 说明 |
|---|---|---|
| `paymentType` | `progress_advance` / `project_advance` / `settlement_payment` | 进度预付 / 项目预付 / 尾款付款 |
| `approvalStatus` | `draft / reviewing / approved / returned / voided` | 提交后 reviewing;仅 approved 可作废 |
| `kingdeeStatus` | `unsynced / synced / paying / paid / closed / failed` | **paid 是项目预付可核销的前置** |
| `kingdeeLockStatus` | `locked / …` | 已锁定的单据禁止编辑/作废 |
### 3.2 正式结算单(FormalSettlement)
| 字段 | 取值 |
|---|---|
| `approvalStatus` | `draft / reviewing / approved / returned / voided` |
| `paymentStatus` | `unpaid / partial / paid` |
| `kingdeeSyncStatus` | `unsynced / synced / failed` |
| `invoiceStatus` | 应付=收票状态,应收=开票状态:`unreceived / partial / completed` |
### 3.3 预付记录单据状态名(`src/utils/settlementStatus.js`,两个编辑器共用)
| 值 | 名称 |
|---|---|
| `reviewing` | 审批中 |
| `approved` | 审批通过 |
| `paid` | 已付款 |
| `returned` | 已驳回 |
| `voided` | 已作废 |
| `draft` | 草稿 |
### 3.4 正式结算单付款记录合并表(只读)
一张表三类型,按「付款类型」列区分:**进度预付**(来源预结算 `PreSettlementAdvance` 台账)/ **项目预付核销**(核销行,金额=本次核销额)/ **结算尾款**。列名统一为「**申请/核销金额**」;创建时间统一取**底层付款申请创建时间**。
---
## 四、逆向与异常场景
| 场景 | 触发 | 系统行为 |
|---|---|---|
| 付款申请作废(仅 approved) | 付款申请列表「作废」(非金蝶锁定) | 金蝶锁单中禁止作废;汇票付款释放票据余额;**联动作废未付预付记录 + 删除其核销行并刷新受影响正式结算单汇总**(已付事实不回退) |
| 正式结算单作废 | 结算单列表「作废」 | 删本单 FORMAL 核销行,额度释放、详情无残留 |
| 金蝶删单回调(`voidByKingdee`) | 金蝶侧反审核删除 | 同上,删本单核销行 |
| 预结算单作废 | 预结算列表「作废」 | 存在关联预付申请需先作废预付;删 PRE 核销行 |
| 预付记录作废 | 预付台账 | 已付款的不能作废 |
| 金蝶反审核(解锁) | A103-2 连续两轮查不到已推单据 | 判定金蝶侧已删 → `unlockBill` 置 `failed`、清空金蝶单号/内码、**联动作废未付预付记录**(已付不回退);重推后重新建档。**此场景不删核销行**(预付申请仍 approved,核销继续计入),人工作废后才回落 |
| 推送失败 | A103-2/A102-2/A302-2/A303-1 报错 | 落 `failed` → `KingdeeAutoSyncRetryTask` 每 10 分钟自动重推;页面「同步金蝶」/「批量同步」手动兜底;审批接口把失败原因带回前端 warning,审批结果不受影响 |
| 结算调整(金额变更) | 「结算调整单」 | 调整落库前校验**核销合计 ≤ 新结算金额**,超出拒绝并提示先调减核销;落库后 `refreshPaymentSummary` 重算。调整重推:核销行**迁移到新单**(克隆 + 删原单行 + 批量刷新两单),原单作历史存档,收付口径以新单为准 |
| 全额核销 / 已结清 | `paymentStatus=paid` | `isSettlementCompleted` 拦截,不允许再发起结算调整(已确认"全额抵扣即结清") |
---
## 五、接口与数据表索引
### 5.1 后端接口
| 模块 | 主要接口(`/blade-transport` 网关前缀) |
|---|---|
| 预结算单 | `GET /pre-settlement/list|detail`;`POST /pre-settlement/save|submit|approve|return|void`;`POST /pre-settlement/formal-settlement`(轻量标记转正式) |
| 付款申请 | `GET /payment-application/list|detail|reference-amount|project-advance-write-off-options`;`POST /payment-application/save|submit|approve|return|void|sync-kingdee|sync-kingdee-batch|sync-kingdee-result` |
| 正式结算单 | `GET /formal-settlement/list|detail|candidate-pre-settlements|contract-options`;`POST /formal-settlement/save|submit|approve|return|void|sync-kingdee|sync-kingdee-ar-result|retry-kingdee-bill-lock|apply-payment|apply-payments|claim-invoices` |
| 开票申请 | `GET /invoice-application/list|detail|settlement-candidates|settlement-details|receiver-information`;`POST /invoice-application/save|remove|submit|approve|return|void|sync-kingdee|sync-kingdee-invoice-result` |
### 5.2 核心数据表
| 表/实体 | 作用 |
|---|---|
| `PreSettlement` | 预结算单;预付汇总 `advanceNo / advanceAppliedAmount / advancePaidAmount`、`formalSettlementNo` |
| `PreSettlementAdvance` | 预结算预付台账;`advanceNo`(=FKD 单号)、`billStatus`、`kingdeeAdvanceNo` |
| `PaymentApplication` | 付款申请(三种 paymentType) |
| `PaymentApplicationSettlement` | 付款申请 ↔ 结算单关系及分摊 |
| `PaymentApplicationRecord` | 付款记录(A103-4 回填,`voucherJson` 留档) |
| `FormalSettlement` | 正式结算单;`settlementAmount / appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus / kingdeeSyncStatus / invoiceStatus` |
| `FormalSettlementSource` | 来源预结算/明细快照,冗余预付汇总 |
| `FormalSettlementPayment` | 正式结算单尾款付款记录(`paymentType=final`) |
| `SettlementProjectAdvanceWriteOff` | 项目预付核销行(`settlementType`:PRE / FORMAL) |
| `InvoiceApplication` / `InvoiceApplicationSettlement` / `InvoiceApplicationSheet` / `InvoiceApplicationLine` / `InvoiceApplicationInvoice` | 开票申请主单 / 结算单关联与分摊 / 发票张次 / 商品行 / 金蝶回写发票 |
### 5.3 前端页面
| 页面 | 路径 |
|---|---|
| 预结算单列表/表单 | `src/views/settlement/pre-settlement.vue`、`pre-settlement-form.vue`、`components/pre-settlement-editor.vue` |
| 正式结算单列表/表单 | `src/views/settlement/formal-settlement.vue`、`formal-settlement-form.vue`、`components/formal-settlement-editor.vue` |
| 项目预付核销组件 | `src/views/settlement/components/project-advance-write-off.vue` |
| 付款申请列表/表单 | `src/views/payment/payment-application.vue`、`payment-application-form.vue` |
| 开票申请列表/表单 | `src/views/payment/invoice-application.vue`、`invoice-application-form.vue` |
| 状态名公共工具 | `src/utils/settlementStatus.js`、`src/option/settlement/formalSettlementSearch.js` |
---
## 六、已知口径与风险
1. **尾款默认可付额** = 结算金额 − 累计尾款申请 − 核销,**不扣进度预付已付部分**(既有口径)。进度预付占大头时默认额可能偏大,由审批环节把关。
2. **正式结算单 `paidAmount` 现含核销**,下游消费该字段的报表/对账单口径随之变化(对账已确认接受)。
3. **金蝶侧预付抵扣字段**(`y_billtype / y_billid / y_settleamt`)本期**不传**,金蝶报表与本系统"已付含核销"存在时间差(待业务确认后迭代)。
4. **预开票未落地**:预结算直接开票需先按方案改造 `doSyncKingdee` 门禁与数据模型。
5. 存量"approved 未 paid"的核销行未做数据修正(`replaceWriteOffs` 只约束新保存),如需清洗另出脚本。
6. 应收侧预收/收款认领(ReceiptClaim)与收票流程未在本文展开。
@@ -0,0 +1,61 @@
-- 结算调整单接入金蝶(未推送自动推 · 已推送手工冲销后重推新单)
-- 整理日期:2026-10-16
-- 结算调整单:落金蝶单据号(未推送场景回填原单金蝶单号;已推送重推场景回填新单金蝶单号)
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_settlement_adjustment` ADD COLUMN `kingdee_bill_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据号'' AFTER `kingdee_sync_status`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'blade_settlement_adjustment'
AND COLUMN_NAME = 'kingdee_bill_no'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- 正式结算单:来源原单号(重推生成的新单,接续原单来源)
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_formal_settlement` ADD COLUMN `origin_formal_settlement_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''来源原正式结算单号(结算调整重推生成)''',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'blade_formal_settlement'
AND COLUMN_NAME = 'origin_formal_settlement_no'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- 正式结算单:调整标记(被调整的原单)
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_formal_settlement` ADD COLUMN `adjust_status` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''调整状态:adjusted=已被结算调整'' AFTER `origin_formal_settlement_no`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'blade_formal_settlement'
AND COLUMN_NAME = 'adjust_status'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- 正式结算单:来源原单号索引(重推幂等按来源原单号查已克隆新单)
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_formal_settlement` ADD KEY `idx_formal_origin_no` (`origin_formal_settlement_no`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'blade_formal_settlement'
AND INDEX_NAME = 'idx_formal_origin_no'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- 推送金蝶 / 重新推送已合并为单一按钮(按金蝶状态自动分流),复用既有菜单 settlement_adjustment_repush,
-- 菜单已在 blade_settlement_adjustment_20260818.sql 初始化,本脚本无需新增菜单。
+3
View File
@@ -18,6 +18,9 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- 编译打包默认跳过测试,需要执行测试时传 -DskipTests=false -->
<skipTests>true</skipTests>
<!-- Docker仓库服务配置 -->
<docker.username>admin</docker.username>
<docker.namespace>blade</docker.namespace>