!4 fix: 结算调整单重推拦截报错补充具体结算单号
Merge pull request !4 from mindless/feature/20261016-settlement-adjustment-kingdee
This commit is contained in:
+8
@@ -172,4 +172,12 @@ public class FormalSettlement extends TenantEntity {
|
||||
* 作废原因,驳回时记录驳回原因
|
||||
*/
|
||||
private String voidReason;
|
||||
/**
|
||||
* 来源原正式结算单号:结算调整重推生成的新单,指向被调整的原单
|
||||
*/
|
||||
private String originFormalSettlementNo;
|
||||
/**
|
||||
* 调整状态:adjusted-已被结算调整(原单标记)
|
||||
*/
|
||||
private String adjustStatus;
|
||||
}
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
-1
@@ -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;
|
||||
}
|
||||
|
||||
+72
@@ -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;
|
||||
|
||||
}
|
||||
+34
-1
@@ -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);
|
||||
}
|
||||
|
||||
+3
@@ -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";
|
||||
|
||||
|
||||
+16
-1
@@ -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)); }
|
||||
}
|
||||
|
||||
+11
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
+12
@@ -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>
|
||||
|
||||
+27
@@ -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与「原明细/原费项 -> 新明细/新费项」id映射(供调整明细应用到新单)
|
||||
*/
|
||||
CloneResult cloneFormalSettlement(Long sourceSettlementId);
|
||||
|
||||
/**
|
||||
* 结算调整重推收尾:原单标记「已调整」、旧锁解锁、追加重推变更记录(不改原单业务数据)
|
||||
*
|
||||
* @param settlementId 原正式结算单id
|
||||
* @param adjustmentNo 结算调整单号
|
||||
* @param newSettlementNo 重推生成的新单号
|
||||
*/
|
||||
void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo);
|
||||
|
||||
/**
|
||||
* 克隆结果:新单id + 原明细id->新明细id + 原费项id->新费项id
|
||||
*/
|
||||
record CloneResult(Long settlementId, Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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 为终态不返回(前端视为无锁/已解锁)
|
||||
|
||||
+18
-1
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
+2
-2
@@ -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<>());
|
||||
}
|
||||
}
|
||||
|
||||
+260
-9
@@ -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 -> 项目预付核销合计(列表页批量传入避免 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("仅草稿或已驳回的正式结算单允许编辑");
|
||||
|
||||
+17
-13
@@ -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("当前状态不可编辑");
|
||||
|
||||
+42
-2
@@ -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 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度
|
||||
|
||||
+27
-2
@@ -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 -> "金蝶锁单";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-16
@@ -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) {
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+182
-16
@@ -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 -> 目标结算明细id(原单场景传空 Map)
|
||||
* @param feeIdMap 原明细费项id -> 目标明细费项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,
|
||||
|
||||
+56
@@ -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);
|
||||
|
||||
+61
-11
@@ -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)
|
||||
*
|
||||
|
||||
+27
@@ -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 财务应收单保存 ====================
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -45,6 +45,7 @@ public class FormalSettlementWrapper extends BaseEntityWrapper<FormalSettlement,
|
||||
case "voided" -> "已作废";
|
||||
default -> entity.getApprovalStatus();
|
||||
});
|
||||
vo.setAdjustStatusName("adjusted".equals(entity.getAdjustStatus()) ? "已调整" : null);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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 + ")";
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -94,6 +94,7 @@ public class BfiBlacklistVO implements Serializable {
|
||||
/**
|
||||
* 失信类别[A:禁止交易类, B:风险警示类]
|
||||
*/
|
||||
@JsonProperty("awrp_combofield")
|
||||
private String awrpCombofield;
|
||||
/**
|
||||
* 失信类别标题
|
||||
|
||||
+23
-1
@@ -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 初始化,本脚本无需新增菜单。
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user