diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java index c1ebf5b5..b55af34f 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java @@ -309,12 +309,13 @@ public interface ISysClient { R getRegion(@RequestParam("code") String code); /** - * 获取启用的费用项 + * 获取费用项。默认仅返回启用档案;includeDisabled 为 true 时同时返回停用档案。 * + * @param includeDisabled 是否包含停用费用项 * @return 费用项集合 */ @GetMapping(FEE_ITEMS) - R> getFeeItems(); + R> getFeeItems(@RequestParam(value = "includeDisabled", required = false) Boolean includeDisabled); @GetMapping(CARGO_TYPES) R> getCargoTypes(); diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java index b7704b68..bfb7d123 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java @@ -165,7 +165,7 @@ public class ISysClientFallback implements ISysClient { } @Override - public R> getFeeItems() { + public R> getFeeItems(Boolean includeDisabled) { return R.fail("获取数据失败"); } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java index bd1bd7a4..b58f8e73 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java @@ -72,4 +72,19 @@ public class OaOrgSyncPageVO implements Serializable { @Schema(description = "全量同步结束后原始数据层级 JSON 文件路径") private String dumpFilePath; + + @Schema(description = "当前同步的公司 OA 主键(部门阶段按公司轮询时使用)") + private String companyOaId; + + @Schema(description = "当前公司序号,从 1 开始") + private Integer companyIndex; + + @Schema(description = "待同步公司总数") + private Integer companyTotal; + + @Schema(description = "当前公司的部门是否已同步完") + private Boolean companyFinished; + + @Schema(description = "下一公司 OA 主键;当前公司完成后回传,前端据此切换并重置页码") + private String nextCompanyOaId; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java index bb453e14..d608d7cb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java @@ -35,6 +35,7 @@ public class PaymentApplicationSaveRequest implements Serializable { private Long settlementId; private List settlementIds; private Long preSettlementId; + private List preSettlementIds; private Long projectId; private String projectName; private Long deptId; @@ -60,4 +61,6 @@ public class PaymentApplicationSaveRequest implements Serializable { private String remark; private List invoices; private List paymentRecords; + /** 多张结算单时按行提交的付款金额 */ + private List settlementLines; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java new file mode 100644 index 00000000..78f28a11 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java @@ -0,0 +1,43 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** 付款申请多结算单付款明细。 @author Chill */ +@Data +public class PaymentApplicationSettlementLineRequest implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + private Long formalSettlementId; + private Long preSettlementId; + private BigDecimal paymentRatio; + private BigDecimal appliedAmount; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementDetailAdjustRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementDetailAdjustRequest.java index bbfddec7..8d2383a9 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementDetailAdjustRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementDetailAdjustRequest.java @@ -33,7 +33,7 @@ public class PreSettlementDetailAdjustRequest implements Serializable { @Schema(description = "预结算明细ID") private Long detailId; - @Schema(description = "调整原因") + @Schema(description = "备注(写入结算明细备注,非必填)") private String changeReason; @Schema(description = "明细费用行") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java index 640ac839..04f1cd42 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java @@ -63,4 +63,10 @@ public class ReceivablePayableFeeCalculateRequest implements Serializable { @Schema(description = "动态费用项目") private Map feeItems; + @Schema(description = "运输单价") + private BigDecimal unitPrice; + + @Schema(description = "本次调整字段:transportQuantity、mileage、unitPrice 或费用项名称") + private String changedField; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java index 0a03304b..cc4ddd1a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java @@ -172,4 +172,12 @@ public class FormalSettlement extends TenantEntity { * 作废原因,驳回时记录驳回原因 */ private String voidReason; + /** + * 来源原正式结算单号:结算调整重推生成的新单,指向被调整的原单 + */ + private String originFormalSettlementNo; + /** + * 调整状态:adjusted-已被结算调整(原单标记) + */ + private String adjustStatus; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementChangeRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementChangeRecord.java index b3834bc4..8d95679f 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementChangeRecord.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementChangeRecord.java @@ -25,6 +25,10 @@ public class FormalSettlementChangeRecord extends TenantEntity { private Integer lineNo; private String operationType; private String changeContent; + /** 变更前数据JSON */ + private String beforeData; + /** 变更后数据JSON */ + private String afterData; private String changeReason; private String operatorName; private LocalDateTime changeTime; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetail.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetail.java index 899dffe1..96f273cd 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetail.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetail.java @@ -8,7 +8,9 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.core.tenant.mp.TenantEntity; @@ -123,6 +125,12 @@ public class FormalSettlementDetail extends TenantEntity { * 批次号 */ private String batchNo; + /** + * 计费方式(来源应收应付货物费用计费类型,非本表字段) + */ + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; /** * 运输单价 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetailFee.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetailFee.java index c4e62e26..0dcae4f5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetailFee.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetailFee.java @@ -8,7 +8,9 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.core.tenant.mp.TenantEntity; @@ -70,6 +72,12 @@ public class FormalSettlementDetailFee extends TenantEntity { * 费用项JSON */ private String feeItemsJson; + /** + * 命中计费规则JSON(来源费用带回,非本表字段) + */ + @TableField(exist = false) + @Schema(description = "命中计费规则JSON") + private String billingRulesJson; /** * 原金额 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java index 0f4dd8ad..08f967b8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java @@ -1,5 +1,6 @@ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; @@ -17,7 +18,12 @@ public class PaymentApplicationSettlement extends TenantEntity { private Long paymentApplicationId; private Long formalSettlementId; private String formalSettlementNo; + private Long preSettlementId; + private String preSettlementNo; private BigDecimal settlementAmount; private BigDecimal appliedAmount; private BigDecimal paidAmount; + /** 查看时回填的可付款金额,不落库 */ + @TableField(exist = false) + private BigDecimal payableAmount; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java index 2539ca07..76c5653c 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java @@ -9,6 +9,7 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -99,6 +100,10 @@ public class PreSettlementDetail extends TenantEntity { @Schema(description = "批次号") private String batchNo; + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; + @Schema(description = "运输单价") private BigDecimal unitPrice; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java index 2b7834af..888fcce8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java @@ -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; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java index 88a3bcd4..9cecf4af 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java @@ -107,6 +107,9 @@ public class SettlementImportBatch extends TenantEntity { @Schema(description = "动态费用列名JSON") private String feeItemNamesJson; + @Schema(description = "导入明细行JSON") + private String rowsJson; + @Schema(description = "备注") private String remark; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportReconciliationExternal.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportReconciliationExternal.java index 353febe3..7cc632d4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportReconciliationExternal.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportReconciliationExternal.java @@ -6,6 +6,8 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; @@ -41,6 +43,7 @@ public class TransportReconciliationExternal extends TenantEntity { private BigDecimal freightAmount; private String feeItemsJson; private BigDecimal settlementAmount; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Boolean suspectedDuplicate; private String matchStatus; private Long matchedInternalId; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java index dc889541..1c68aef6 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java @@ -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 sources; @@ -70,6 +72,7 @@ public class FormalSettlementVO extends FormalSettlement implements KingdeeUnloc @TableField(exist = false) @Schema(description = "开票申请经金蝶回写的发票结果(含文件地址,只读展示)") private List issuedInvoices; @TableField(exist = false) private List paymentApplications; + @TableField(exist = false) private List advancePayments; @TableField(exist = false) private List adjustments; @TableField(exist = false) private List changeRecords; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationReferenceAmountVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationReferenceAmountVO.java index 367ae478..d750370b 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationReferenceAmountVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationReferenceAmountVO.java @@ -27,4 +27,6 @@ public class PaymentApplicationReferenceAmountVO implements Serializable { @Schema(description = "结算金额") private BigDecimal settlementAmount; @Schema(description = "累计申请付款金额") private BigDecimal cumulativeAppliedAmount; @Schema(description = "可付款金额") private BigDecimal payableAmount; + /** 该结算单当前为第几次付款(含本次,已作废不计) */ + @Schema(description = "付款次序") private Integer paymentSequence; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java index f32a9491..3f105412 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java @@ -39,6 +39,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * 收款流水视图实体类 @@ -91,4 +92,9 @@ public class ReceiptFlowVO extends KingdeeReceiptFlow { private BigDecimal receiptAmountEnd; @TableField(exist = false) private List claimRecords = new ArrayList<>(); + /** + * 本流水历史关联且仍有剩余可认领金额的应收正式结算单(继续认领时预填) + */ + @TableField(exist = false) + private List> relatedSettlements = new ArrayList<>(); } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java index 44857626..5fe0ab5d 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java @@ -73,6 +73,10 @@ public class ReceivablePayableDetailVO extends ReceivablePayableDetail { @Schema(description = "状态名称") private String settlementStatusName; + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; + @TableField(exist = false) @Schema(description = "费用项目") private Map feeItems; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdjustmentVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdjustmentVO.java index ccbb245e..3f00b3a4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdjustmentVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdjustmentVO.java @@ -17,7 +17,6 @@ public class SettlementAdjustmentVO extends SettlementAdjustment { private String approvalStatusName; private String settlementTypeName; private String createUserName; - private String kingdeeBillNo; private List details; private List formalDetails; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdvancePaymentVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdvancePaymentVO.java new file mode 100644 index 00000000..9ec5616e --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdvancePaymentVO.java @@ -0,0 +1,72 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java index 3f856a62..6887da79 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java @@ -32,6 +32,7 @@ import org.springblade.transport.pojo.entity.SettlementImportBatch; import java.io.Serial; import java.util.List; +import java.util.Map; /** * 导入结算单批次视图 @@ -66,4 +67,7 @@ public class SettlementImportBatchVO extends SettlementImportBatch { @Schema(description = "动态费用列名") private List feeItemNames; + + @Schema(description = "导入明细行") + private List> rows; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java index aa4daff4..ec68737b 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java @@ -17,7 +17,40 @@ public interface ISettlementAdjustmentService { Long saveDraft(SettlementAdjustmentSaveRequest request); void removeDraft(Long id); void submit(SettlementAdjustmentStatusRequest request); - void approve(SettlementAdjustmentStatusRequest request); + /** + * 审批通过:未推送场景回写原单并发布金蝶自动推送事件;已推送场景原单零改动仅追加变更记录。 + * + * @param request 审批请求 + * @return 已推送场景的手工冲销提示文案,未推送场景返回 null + */ + String approve(SettlementAdjustmentStatusRequest request); void returnBill(SettlementAdjustmentStatusRequest request); String repush(Long adjustmentId); + + /** + * 选单后金蝶冲销预检(软提示,不阻断建单):未推送 / 已推送已冲销 / 已推送未冲销; + * 金蝶查询异常降级返回「未校验」,由审批分流与重推闸门兜底 + * + * @param formalSettlementId 选定正式结算单id + * @return syncStatus / writtenOff / message + */ + Map kingdeeWriteOffPrecheck(Long formalSettlementId); + + /** + * 推送金蝶(未推送/生成失败场景):推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态 + * + * @param adjustmentId 结算调整单id + * @return 金蝶单据编号 + */ + String syncKingdee(Long adjustmentId); + + /** + * 重推克隆+应用(独立事务,由 repush 经自代理调用):克隆原单为新正式结算单并应用调整, + * 供推送失败重试时幂等复用 + * + * @param originalSettlementId 原正式结算单id + * @param adjustmentId 结算调整单id + * @return 新正式结算单id + */ + Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId); } diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java index 398ba356..d20b5bb8 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java @@ -132,6 +132,10 @@ public class User extends TenantEntity implements IPhone { * 是否是oa的账号 */ private Integer isOa; + /** + * OA登录名 + */ + private String oaLoginId; /** * OA上次更新时间(OA 的 modified 字段),仅同步比对使用 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java b/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java index f65cc45e..2cf6ad79 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java @@ -11,7 +11,6 @@ import lombok.AllArgsConstructor; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; -import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.StringUtil; import org.springblade.process.pojo.dto.ApprovalDTO; @@ -49,7 +48,6 @@ public class BusinessProcessController extends BladeController { if (param == null) { param = new ApprovalDTO(); } - param.setLoginName(AuthUtil.getUserAccount()); IPage pages = businessProcessService.queryMkApprovalList(Condition.getPage(query), param); return R.data(pages); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java b/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java index 36b15ce0..ae11c22d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java @@ -71,7 +71,7 @@ public interface IBusinessProcessService extends IService { * 获取流程当前节点详情 * * @param processInstanceId 流程实例id - * @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析 + * @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析 * @return 当前节点列表 */ List getCurrentNodes(String processInstanceId, String loginName); @@ -80,7 +80,7 @@ public interface IBusinessProcessService extends IService { * 获取流程实例详情 * * @param processInstanceId 流程实例id - * @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析 + * @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析 * @return 流程实例详情 */ Object getProcessInfo(String processInstanceId, String loginName); diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java index fed83a70..42326194 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java @@ -132,9 +132,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl queryMkApprovalList(IPage page, ApprovalDTO param) { + String loginName = resolveCurrentUserOaLoginId(); + AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法查询审核列表"); + param.setLoginName(loginName); if (MKDoc.RELATED.getCode().equals(param.getDocType())) { // 我参与的,调用流程列表接口 MKProcessDTO processParam = approvalConvert.dto2mk(param); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java index 39d60f72..47ef9a82 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java @@ -204,7 +204,7 @@ public class DeptController extends BladeController { } /** - * 从OA按页同步部门(需先完成公司同步) + * 从OA按页同步部门(需先完成公司同步;按公司 oaId 轮询查询) */ @IsAdmin @PostMapping("/sync-oa-department") @@ -214,8 +214,9 @@ public class DeptController extends BladeController { @RequestParam(defaultValue = "1") Integer current, @RequestParam(defaultValue = "20") Integer size, @RequestParam(defaultValue = "false") Boolean incremental, - @RequestParam(required = false) String modifiedSince) { - return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); + @RequestParam(required = false) String modifiedSince, + @RequestParam(required = false) String companyOaId) { + return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince, companyOaId)); } /** diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java index 00b2e576..abf59acf 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java @@ -70,6 +70,7 @@ public interface UserConvert { @Mapping(source = "lastname", target = "realName") @Mapping(source = "mobile", target = "phone") @Mapping(source = "email", target = "email") + @Mapping(source = "loginid", target = "oaLoginId") @Mapping(source = "modified", target = "oaModified") User baseConvert(OAPersonResponse person); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java index edacf506..a4cf9718 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java @@ -32,6 +32,7 @@ import org.springblade.core.tool.api.R; import org.springblade.system.pojo.entity.*; import org.springblade.system.service.*; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Hidden; @@ -218,9 +219,9 @@ public class SysClient implements ISysClient { @Override @GetMapping(FEE_ITEMS) - public R> getFeeItems() { + public R> getFeeItems(@RequestParam(value = "includeDisabled", required = false) Boolean includeDisabled) { return R.data(feeItemService.list(Wrappers.lambdaQuery() - .eq(FeeItem::getStatus, 1) + .eq(!Boolean.TRUE.equals(includeDisabled), FeeItem::getStatus, 1) .eq(FeeItem::getIsDeleted, 0) .orderByAsc(FeeItem::getFeeCategory, FeeItem::getName))); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java index 76a4be9f..a252c387 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java @@ -45,13 +45,13 @@ public interface UserMapper extends BaseMapper { /** * 自定义分页 * - * @param page - * @param user - * @param deptIdList - * @param tenantId - * @return + * @param page 分页 + * @param user 查询条件 + * @param deptId 部门 ID(含自身及子孙,由 SQL FIND_IN_SET 过滤;为空则不过滤部门) + * @param tenantId 租户 ID + * @return 用户列表 */ - List selectUserPage(IPage page, @Param("user") User user, @Param("deptIdList") List deptIdList, @Param("tenantId") String tenantId); + List selectUserPage(IPage page, @Param("user") User user, @Param("deptId") Long deptId, @Param("tenantId") String tenantId); /** * 获取用户 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml index 23fff0b7..6921159c 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml @@ -26,6 +26,7 @@ + @@ -61,20 +62,21 @@ and bu.status = #{user.status} - + and bu.id in ( - SELECT - user_id - FROM - blade_user_dept - WHERE - status = 1 and is_deleted = 0 and dept_id IN - - #{item} - + select distinct bud.user_id + from blade_user_dept bud + where bud.status = 1 + and bud.is_deleted = 0 + and bud.dept_id in ( + select bd.id + from blade_dept bd + where bd.is_deleted = 0 + and (bd.id = #{deptId} or find_in_set(#{deptId}, bd.ancestors) > 0) + ) ) - ORDER BY bu.id + ORDER BY bu.create_time DESC, bu.id DESC SELECT FROM blade_kingdee_invoice_pool p WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId} + + + AND p.invoice_no LIKE CONCAT('%', #{query.invoiceNo}, '%') diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java index 770dc202..f1507f68 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java @@ -21,9 +21,20 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PaymentApplication; /** 付款申请 Mapper。 @author Chill */ @Mapper public interface PaymentApplicationMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_payment_application WHERE tenant_id = #{tenantId} " + + "AND payment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java index c8bb1558..734364da 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java @@ -10,6 +10,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PreSettlementAdvance; /** @@ -19,4 +21,12 @@ import org.springblade.transport.pojo.entity.PreSettlementAdvance; */ @Mapper public interface PreSettlementAdvanceMapper extends BaseMapper { + + /** + * 查询预付单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(advance_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement_advance WHERE tenant_id = #{tenantId} " + + "AND advance_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java index c2b86484..2fbe0db4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java @@ -10,6 +10,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PreSettlement; /** @@ -19,4 +21,20 @@ import org.springblade.transport.pojo.entity.PreSettlement; */ @Mapper public interface PreSettlementMapper extends BaseMapper { + + /** + * 查询预结算单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(pre_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} " + + "AND pre_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxPreSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); + + /** + * 查询预结算转正式结算单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} " + + "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxFormalSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java index 1a2b5629..9a7b7e64 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java @@ -2,7 +2,19 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.SettlementAdjustment; @Mapper -public interface SettlementAdjustmentMapper extends BaseMapper {} +public interface SettlementAdjustmentMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大结算调整单流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(adjustment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_settlement_adjustment WHERE tenant_id = #{tenantId} " + + "AND adjustment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementProjectAdvanceWriteOffMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementProjectAdvanceWriteOffMapper.java index a9aa2767..973be9bf 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementProjectAdvanceWriteOffMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementProjectAdvanceWriteOffMapper.java @@ -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> sumWriteOffBySettlementIds(@Param("settlementType") String settlementType, + @Param("settlementIds") Collection settlementIds); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementProjectAdvanceWriteOffMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementProjectAdvanceWriteOffMapper.xml index 3c21ab32..5fd71743 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementProjectAdvanceWriteOffMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementProjectAdvanceWriteOffMapper.xml @@ -32,4 +32,16 @@ AND settlement_id = #{settlementId} + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java index e808e682..db46b800 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java @@ -22,4 +22,13 @@ public interface TransportReconciliationMapper extends BaseMapper formValues) { + String loginName, Long bizId, Map formValues) { Map formValuesCopy = new HashMap<>(formValues == null ? Map.of() : formValues); - log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={}", PROCESS_INFO_DELAY_SECONDS, bizType, processId); + log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={} bizId={}", + PROCESS_INFO_DELAY_SECONDS, bizType, processId, bizId); + // 延迟任务跑在独立线程,请求态 @TenantIgnore 已丢失,必须显式 ignore,否则 getById 可能因租户条件查空 scheduler.schedule( - () -> queryProcessInfo(handler, bizType, processId, loginName, bizIdText, formValuesCopy), + () -> TenantUtil.ignore(() -> queryProcessInfo(handler, bizType, processId, loginName, bizId, formValuesCopy)), PROCESS_INFO_DELAY_SECONDS, TimeUnit.SECONDS ); } private void queryProcessInfo(IMkProcessBizHandler handler, String bizType, String processId, - String loginName, String bizIdText, Map formValues) { + String loginName, Long bizId, Map formValues) { try { FR processInfoResult = businessProcessClient.getProcessInfo(processId, loginName); Object processInfo = processInfoResult == null ? null : processInfoResult.getData(); - log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} result={}", - bizType, processId, loginName, JSON.toJSONString(processInfo)); + log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} bizId={} result={}", + bizType, processId, loginName, bizId, JSON.toJSONString(processInfo)); if (isProcessFinished(processInfo)) { - applyProcessResult(handler, bizType, bizIdText, processId, formValues, true); + applyProcessResult(handler, bizType, bizId, processId, formValues, true); } else if (MkProcessNodeHelper.isProcessRejected(processInfo)) { - applyProcessResult(handler, bizType, bizIdText, processId, formValues, false); - } else if (StringUtil.isNotBlank(bizIdText)) { - try { - handler.syncFromProcessInfo(Long.valueOf(bizIdText), processInfo); - } catch (NumberFormatException e) { - log.warn("公开页流程消息业务id格式不正确 bizType={} id={}", bizType, bizIdText); - } + applyProcessResult(handler, bizType, bizId, processId, formValues, false); + } else if (bizId != null) { + handler.syncFromProcessInfo(bizId, processInfo); } } catch (Exception e) { - log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={}", bizType, processId, loginName, e); + log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={} bizId={}", + bizType, processId, loginName, bizId, e); } } @@ -123,21 +124,24 @@ public class MkProcessMessageService { return "30".equals(firstText(asMap(processInfo), "fdProcessStatus")); } - private void applyProcessResult(IMkProcessBizHandler handler, String bizType, String bizIdText, + private void applyProcessResult(IMkProcessBizHandler handler, String bizType, Long bizId, String processId, Map formValues, boolean approved) { String action = approved ? "审核通过" : "审核驳回"; - if (StringUtil.isBlank(bizIdText)) { - log.warn("流程{}但未找到业务id,跳过同步 bizType={} processId={}", action, bizType, processId); + if (bizId == null) { + log.warn("流程{}但未解析到业务主键,跳过同步 bizType={} processId={}", action, bizType, processId); return; } try { String processorName = firstText(formValues, "mkUserName", "mkLoginName"); - Long bizId = Long.valueOf(bizIdText); - if (approved) { - handler.approveFromProcess(bizId, processorName); - } else { - handler.rejectFromProcess(bizId, processorName); + boolean synced = approved + ? handler.approveFromProcess(bizId, processorName) + : handler.rejectFromProcess(bizId, processorName); + if (!synced) { + log.error("流程{}同步业务状态失败(业务单据不存在或未更新) bizType={} bizId={} processId={}", + action, bizType, bizId, processId); + return; } + log.info("流程{}同步业务状态成功 bizType={} bizId={} processId={}", action, bizType, bizId, processId); try { FR markResult = businessProcessClient.markProcessFinished(processId, approved); log.info("公开页标记业务流程完结 bizType={} processId={} approved={} result={}", @@ -146,13 +150,56 @@ public class MkProcessMessageService { log.error("公开页标记业务流程完结失败 bizType={} processId={} approved={}", bizType, processId, approved, markException); } - } catch (NumberFormatException e) { - log.warn("流程{}但业务id格式不正确 bizType={} id={}", action, bizType, bizIdText); } catch (Exception e) { - log.error("流程{}同步业务状态失败 bizType={} id={} processId={}", action, bizType, bizIdText, processId, e); + log.error("流程{}同步业务状态失败 bizType={} bizId={} processId={}", action, bizType, bizId, processId, e); } } + /** + * 公开页 formData 里的 id / formInstanceId 可能是表单实例 id(与业务主键不同), + * 须先按 MkFormBind 反查业务主键,与 detail 接口保持一致。 + */ + private Long resolveBizId(Map formData) { + String formInstanceIdText = firstText(formData, "formInstanceId"); + String idText = firstText(formData, "id"); + Long resolved = resolveByFormInstanceId(formInstanceIdText); + if (resolved != null) { + return resolved; + } + resolved = resolveByFormInstanceId(idText); + if (resolved != null) { + return resolved; + } + if (StringUtil.isNotBlank(idText)) { + try { + return Long.valueOf(idText); + } catch (NumberFormatException e) { + log.warn("公开页流程消息业务id格式不正确 id={}", idText); + } + } + return null; + } + + private Long resolveByFormInstanceId(String formInstanceIdText) { + if (StringUtil.isBlank(formInstanceIdText)) { + return null; + } + try { + Long formInstanceId = Long.valueOf(formInstanceIdText); + FR result = businessProcessClient.resolveByFormInstanceId(formInstanceId); + if (result != null && result.isSuccess() && result.getData() != null && result.getData().getBizId() != null) { + Long bizId = result.getData().getBizId(); + log.info("公开页按表单实例id解析业务主键 formInstanceId={} bizId={}", formInstanceId, bizId); + return bizId; + } + } catch (NumberFormatException e) { + log.warn("公开页表单实例id格式不正确 formInstanceId={}", formInstanceIdText); + } catch (Exception e) { + log.warn("公开页按表单实例id解析业务主键失败 formInstanceId={}", formInstanceIdText, e); + } + return null; + } + private static Map asMap(Object value) { if (!(value instanceof Map map)) { return Collections.emptyMap(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java index 3393983e..daf1d429 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java @@ -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 String applyPayment(FormalSettlementPaymentRequest request); List 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 detailIdMap, Map feeIdMap) { + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java index 6438f026..755e8c45 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java @@ -60,6 +60,17 @@ public interface IKingdeeBillLockService extends IService { */ void checkEditable(String sourceBillType, Long sourceBillId); + /** + * 结算调整重推解锁:把源单当前「锁定中」的锁记录标记为 unlocked(非金蝶回调,仅本地解锁留痕), + * 供原结算单被调整重推生成新单后解除旧锁使用;新单另行落新锁记录 + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillId WLTMS 业务主键 + * @param operator 操作人 + * @return 解锁的记录数 + */ + int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator); + /** * 批量查源单当前锁状态(供列表/详情页填充展示标识): * 每源单取最新一条非终态锁记录(locked/failed),unlocked 为终态不返回(前端视为无锁/已解锁) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java index f045a015..5d81be02 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java @@ -66,4 +66,12 @@ public interface ISettlementImportBatchService extends BaseService> historyWaybills(SettlementImportBatchRequest request); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java index 9c1d7368..3c8886d8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java @@ -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 listOptions(Long contractId, String excludeSettlementType, Long excludeSettlementId); @@ -39,6 +41,11 @@ public interface ISettlementProjectAdvanceWriteOffService { */ BigDecimal sumBySettlement(String settlementType, Long settlementId); + /** + * 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用)。 + */ + Map sumBySettlementIds(String settlementType, Collection settlementIds); + /** * 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。 */ @@ -50,4 +57,14 @@ public interface ISettlementProjectAdvanceWriteOffService { */ void deleteBySettlement(String settlementType, Long settlementId); + /** + * 删除指定结算单集合上的核销明细(核销行转移/结算单作废联动),返回删除行数。 + */ + int deleteBySettlementIds(String settlementType, Collection settlementIds); + + /** + * 按付款申请删除其核销明细(付款申请作废联动),返回受影响的正式结算单id。 + */ + List deleteByPaymentApplicationIds(Collection paymentApplicationIds); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementRuleAutoGenerateService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementRuleAutoGenerateService.java new file mode 100644 index 00000000..4542b599 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementRuleAutoGenerateService.java @@ -0,0 +1,42 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +/** + * 按合同结算规则自动生成预结算单、正式结算单 + * + * @author Chill + */ +public interface ISettlementRuleAutoGenerateService { + + /** + * 扫描已开启自动生成的合同,为截至昨日已关闭的账期生成结算草稿。 + * + * @return 本次生成结果摘要 + */ + String generateDueSettlements(); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java index aeac5967..b34f733c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java @@ -46,11 +46,11 @@ public record KingdeeSyncContext(Map contractCache, Map> settlementDetailCache, Map settlementCache, Map receiptAccountCache, - Map feeCategoryByName) { + Map feeCategoryByName, Map 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<>()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java index 0e08c8a2..a2728da7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java @@ -104,12 +104,17 @@ public class BillLedgerServiceImpl extends BaseServiceImpl availableList(String keyword, Long deptId, Long selectedId) { + LocalDate today = LocalDate.now(); + // 付款申请仅可选:收票类型、未到期、有可用余额;已选中的汇票始终带回以便回显 return list(Wrappers.lambdaQuery() .and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(BillLedger::getBillNo, keyword) .or().like(BillLedger::getIssuerName, keyword) .or().like(BillLedger::getReceiverName, keyword)) .and(wrapper -> wrapper - .gt(BillLedger::getAvailableBalance, BigDecimal.ZERO) + .and(available -> available + .eq(BillLedger::getBillType, "received") + .ge(BillLedger::getMaturityDate, today) + .gt(BillLedger::getAvailableBalance, BigDecimal.ZERO)) .or(selectedId != null, child -> child.eq(BillLedger::getId, selectedId))) .orderByAsc(BillLedger::getMaturityDate) .orderByDesc(BillLedger::getCreateTime)).stream() diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillPaymentServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillPaymentServiceImpl.java index f0bd1f55..ec93da0a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillPaymentServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillPaymentServiceImpl.java @@ -309,10 +309,11 @@ public class BillPaymentServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(BillPayment::getPaymentNo, prefix)) + 1); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } private BigDecimal positive(BigDecimal value, String name) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index c68b021d..62e6a744 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -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 result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime)); - IPage voPage = result.convert(this::toVO); + // 本页应付结算单的项目预付核销合计一次查询,避免逐行 toVO 重算汇总时的 N+1 + Map writeOffAmounts = this.projectAdvanceWriteOffService.sumBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, + result.getRecords().stream() + .filter(item -> "payable".equals(item.getSettlementType())) + .map(FormalSettlement::getId).toList()); + IPage voPage = result.convert(item -> this.toVO(item, writeOffAmounts)); fillAttachmentSyncStatus(voPage.getRecords()); fillKingdeeLockStatus(voPage.getRecords()); return voPage; @@ -286,21 +301,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl throw new ServiceException("结算类型不正确"); }; String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - List numbers = list(Wrappers.lambdaQuery() - .select(FormalSettlement::getFormalSettlementNo) - .likeRight(FormalSettlement::getFormalSettlementNo, prefix)) - .stream().map(FormalSettlement::getFormalSettlementNo).toList(); - int sequence = 1; - if (!numbers.isEmpty()) { - String latest = numbers.stream().max(String::compareTo).orElse(prefix); - try { - sequence = Integer.parseInt(latest.substring(prefix.length())) + 1; - } catch (RuntimeException ignored) { - sequence = Math.toIntExact(count(Wrappers.lambdaQuery() - .likeRight(FormalSettlement::getFormalSettlementNo, prefix))) + 1; - } - } - return prefix + String.format("%05d", sequence); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } @Override @@ -314,6 +316,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl details = detailMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementDetail::getFormalSettlementId, id).orderByAsc(FormalSettlementDetail::getLineNo)); fillDetailWaybillAddresses(details); + fillDetailCargoNames(details); + fillDetailBillingTypes(details); vo.setDetails(details); vo.setSummaryFees(listSummaryFees(id)); vo.setPayments(paymentMapper.selectList(Wrappers.lambdaQuery() @@ -322,6 +326,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relationApplicationIds = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .select(PaymentApplicationSettlement::getPaymentApplicationId) @@ -344,13 +349,43 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(SettlementAdjustment::getFormalSettlementId, id) .orderByDesc(SettlementAdjustment::getCreateTime)).stream() - .map(item -> toAdjustmentVO(item, settlement.getKingdeeBillNo())).toList()); + .map(this::toAdjustmentVO).toList()); vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementChangeRecord::getFormalSettlementId, id) .orderByDesc(FormalSettlementChangeRecord::getChangeTime))); return vo; } + /** + * 明细展示计费方式:按来源应收应付货物费用的计费类型汇总(去重后顿号拼接)。 + */ + private void fillDetailBillingTypes(List details) { + if (details == null || details.isEmpty()) { + return; + } + List sourceDetailIds = details.stream() + .map(FormalSettlementDetail::getSourceDetailId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (sourceDetailIds.isEmpty()) { + return; + } + Map billingTypeMap = receivablePayableCargoFeeMapper.selectList( + Wrappers.lambdaQuery() + .in(ReceivablePayableCargoFee::getDetailId, sourceDetailIds) + .orderByAsc(ReceivablePayableCargoFee::getLineNo)) + .stream() + .filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) + .collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId, + Collectors.mapping(ReceivablePayableCargoFee::getBillingType, + Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new), + values -> String.join("、", values))))); + for (FormalSettlementDetail detail : details) { + detail.setBillingType(billingTypeMap.getOrDefault(detail.getSourceDetailId(), "")); + } + } + /** * 为历史正式结算明细补充运单收发货信息,保留明细中已有的快照值。 */ @@ -545,12 +580,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl新id 映射(调整明细应用到新单时按映射定位) + Map detailIdMap = new HashMap<>(); + Map feeIdMap = new HashMap<>(); + List details = detailMapper.selectList(Wrappers.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.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.lambdaQuery() + .eq(FormalSettlementSource::getFormalSettlementId, sourceSettlementId))) { + FormalSettlementSource cloneRelation = Objects.requireNonNull(BeanUtil.copyProperties(relation, FormalSettlementSource.class)); + resetAuditFields(cloneRelation); + cloneRelation.setFormalSettlementId(clone.getId()); + sourceMapper.insert(cloneRelation); + } + // 核销行迁移:克隆到新单并删除原单行,避免预付可核销额度双占(原单为调整存档,收付口径以新单为准) + List writeOffRows = this.projectAdvanceWriteOffMapper.selectList( + Wrappers.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.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; @@ -700,6 +858,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl 0) { + // 结算调整单回填:未推送场景审批通过后自动推送失败,调整单置 failed 供「推送金蝶」重试 + settlementAdjustmentMapper.update(null, Wrappers.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, "调整", @@ -805,7 +968,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaUpdate() + .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; @@ -1089,9 +1258,23 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl detailFees(Long detailId) { FormalSettlementDetail detail = detailMapper.selectById(detailId); if (detail == null || Objects.equals(detail.getIsDeleted(), 1)) throw new ServiceException("正式结算明细不存在"); - return detailFeeMapper.selectList(Wrappers.lambdaQuery() + List fees = detailFeeMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementDetailFee::getFormalSettlementDetailId, detailId) .orderByAsc(FormalSettlementDetailFee::getLineNo)); + List sourceFeeIds = fees.stream().map(FormalSettlementDetailFee::getSourceFeeId) + .filter(Objects::nonNull).toList(); + if (sourceFeeIds.isEmpty()) { + return fees; + } + Map sourceFeeMap = receivablePayableCargoFeeMapper.selectBatchIds(sourceFeeIds).stream() + .collect(Collectors.toMap(ReceivablePayableCargoFee::getId, Function.identity(), (left, right) -> left)); + fees.forEach(fee -> { + ReceivablePayableCargoFee sourceFee = sourceFeeMap.get(fee.getSourceFeeId()); + if (sourceFee != null) { + fee.setBillingRulesJson(sourceFee.getBillingRulesJson()); + } + }); + return fees; } @Override @@ -1101,11 +1284,15 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl existing = detailFees(detail.getId()).stream() .collect(Collectors.toMap(FormalSettlementDetailFee::getId, item -> item)); if (existing.size() != request.getRows().size()) throw new ServiceException("费用调整行数据不完整"); List changes = new java.util.ArrayList<>(); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); for (PreSettlementDetailAdjustRequest.FeeRow row : request.getRows()) { FormalSettlementDetailFee fee = existing.get(row.getId()); if (fee == null) throw new ServiceException("存在无效的货物费用行"); @@ -1118,7 +1305,6 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl rows = detailFees(detail.getId()); detail.setTransportQuantity(rows.stream().map(FormalSettlementDetailFee::getTransportQuantity).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + detail.setMileage(rows.stream().map(FormalSettlementDetailFee::getMileage).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + // 运输单价无法按行简单求和,与前端暂存逻辑一致:取首行费用单价。 + detail.setUnitPrice(rows.stream().map(FormalSettlementDetailFee::getUnitPrice).filter(Objects::nonNull).findFirst().orElse(null)); detail.setFreightAmount(rows.stream().map(FormalSettlementDetailFee::getFreightAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); detail.setOriginalAmount(rows.stream().map(FormalSettlementDetailFee::getOriginalAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); detail.setSettlementAmountTax(rows.stream().map(FormalSettlementDetailFee::getSettlementAmountTax).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); - detail.setSettlementAmountNoTax(rows.stream().map(FormalSettlementDetailFee::getSettlementAmountNoTax).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + boolean hasNoTax = rows.stream().anyMatch(fee -> fee.getSettlementAmountNoTax() != null); + detail.setSettlementAmountNoTax(hasNoTax + ? rows.stream().map(FormalSettlementDetailFee::getSettlementAmountNoTax).filter(Objects::nonNull).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add) + : null); detail.setAdjustAmount(detail.getSettlementAmountTax().subtract(detail.getOriginalAmount())); - detail.setRemark(reason); + Map feeItems = new LinkedHashMap<>(); + rows.forEach(fee -> parseFeeItems(fee.getFeeItemsJson()).forEach((name, amount) -> + feeItems.merge(name, money(amount), BigDecimal::add))); + detail.setFeeItemsJson(JsonUtil.toJson(feeItems)); + detail.setRemark(detailRemark); detailMapper.updateById(detail); + appendTextChange(changes, beforeData, afterData, "备注", beforeDetailRemark, detailRemark); + if (changes.isEmpty()) { + throw new ServiceException("未修改任何结算明细费用"); + } rebuildSummaryFees(settlement.getId()); refreshSettlementAmount(settlement); saveChange(settlement.getId(), "结算明细项", detail.getLineNo(), "调整", - changes.isEmpty() ? "调整结算明细费用" : String.join(";", changes), reason); + String.join(";", changes), detailRemark, beforeData, afterData); } private void rebuildSummaryFees(Long settlementId) { @@ -1313,10 +1513,18 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl changes = new java.util.ArrayList<>(); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount()); + appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount()); + appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark()); + if (!changes.isEmpty()) { + saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "", + beforeData, afterData); + } } for (FormalSettlementSummaryFee manualRow : existingManualRows) { - if (!retainedManualIds.contains(manualRow.getId())) summaryFeeMapper.deleteById(manualRow.getId()); + if (!retainedManualIds.contains(manualRow.getId())) { + summaryFeeMapper.deleteById(manualRow.getId()); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + beforeData.put("费用类型", manualRow.getFeeType()); + beforeData.put("费用项", manualRow.getFeeItem()); + beforeData.put("调整金额", money(manualRow.getAdjustAmount())); + beforeData.put("结算金额", money(manualRow.getSettlementAmount())); + beforeData.put("备注", manualRow.getRemark()); + afterData.put("费用类型", ""); + afterData.put("费用项", ""); + afterData.put("调整金额", BigDecimal.ZERO.setScale(2)); + afterData.put("结算金额", BigDecimal.ZERO.setScale(2)); + afterData.put("备注", ""); + saveChange(settlementId, "合计费用项", manualRow.getLineNo(), "删除", + "删除合计费用项【" + manualRow.getFeeItem() + "】金额【" + money(manualRow.getSettlementAmount()) + "】", + "", beforeData, afterData); + } } renumberSummaryFees(settlementId); } @@ -1489,11 +1729,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl receiptClaimPaidBySettlement = hasReceivable ? receiptClaimSettlementMapper.selectList(Wrappers.lambdaQuery() .in(ReceiptClaimSettlement::getFormalSettlementId, settledIds) - .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream() .collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId, Collectors.reducing(BigDecimal.ZERO, item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add))) : Map.of(); + // 项目预付核销合计(仅应付结算单需要,一次 group-by 避免逐单查询) + Map writeOffAmountsBySettlement = hasPayable + ? this.projectAdvanceWriteOffService.sumBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settledIds) + : Map.of(); for (FormalSettlement settlement : settlements) { List relations = relationsBySettlement @@ -1509,7 +1755,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl details) { + if (details == null || details.isEmpty()) { + return; + } + List waybillIds = details.stream().map(FormalSettlementDetail::getWaybillId) + .filter(Objects::nonNull).distinct().toList(); + Map waybillMap = waybillIds.isEmpty() ? Map.of() : waybillService.listByIds(waybillIds).stream() + .collect(Collectors.toMap(Waybill::getId, Function.identity(), (left, right) -> left)); + List detailIds = details.stream().map(FormalSettlementDetail::getId).filter(Objects::nonNull).toList(); + Map> feesByDetail = detailIds.isEmpty() ? Map.of() + : detailFeeMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetailFee::getFormalSettlementDetailId, detailIds) + .orderByAsc(FormalSettlementDetailFee::getLineNo)).stream() + .collect(Collectors.groupingBy(FormalSettlementDetailFee::getFormalSettlementDetailId, LinkedHashMap::new, Collectors.toList())); + for (FormalSettlementDetail detail : details) { + Waybill waybill = waybillMap.get(detail.getWaybillId()); + LinkedHashSet goodsNames = new LinkedHashSet<>(); + LinkedHashSet goodsTypes = new LinkedHashSet<>(); + if (waybill != null) { + collectGoodsCargo(waybill.getGoodsJson(), goodsNames, goodsTypes); + } + LinkedHashSet feeNames = new LinkedHashSet<>(); + LinkedHashSet feeTypes = new LinkedHashSet<>(); + for (FormalSettlementDetailFee fee : feesByDetail.getOrDefault(detail.getId(), List.of())) { + addCargoToken(feeNames, fee.getCargoName(), true); + addCargoToken(feeTypes, fee.getCargoType(), true); + } + LinkedHashSet names = goodsNames.size() > 1 ? goodsNames : feeNames.size() > 1 ? feeNames : goodsNames; + LinkedHashSet types = goodsTypes.size() > 1 ? goodsTypes : feeTypes.size() > 1 ? feeTypes : goodsTypes; + if (names.isEmpty()) { + addCargoToken(names, detail.getCargoName(), true); + } + if (types.isEmpty()) { + addCargoToken(types, detail.getCargoType(), true); + } + if (names.size() > 1) { + detail.setCargoName(joinCargo(names)); + } + if (types.size() > 1) { + detail.setCargoType(joinCargo(types)); + } + } + } + + private void persistDetailCargoNames(Long settlementId) { + List details = detailMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, settlementId) + .orderByAsc(FormalSettlementDetail::getLineNo)); + Map cargoNames = new LinkedHashMap<>(); + Map cargoTypes = new LinkedHashMap<>(); + for (FormalSettlementDetail detail : details) { + cargoNames.put(detail.getId(), detail.getCargoName()); + cargoTypes.put(detail.getId(), detail.getCargoType()); + } + fillDetailCargoNames(details); + for (FormalSettlementDetail detail : details) { + if (Objects.equals(cargoNames.get(detail.getId()), detail.getCargoName()) + && Objects.equals(cargoTypes.get(detail.getId()), detail.getCargoType())) { + continue; + } + detailMapper.update(null, Wrappers.lambdaUpdate() + .eq(FormalSettlementDetail::getId, detail.getId()) + .set(FormalSettlementDetail::getCargoName, detail.getCargoName()) + .set(FormalSettlementDetail::getCargoType, detail.getCargoType())); + } + } + + private void collectGoodsCargo(String goodsJson, Set names, Set types) { + if (Func.isEmpty(goodsJson)) { + return; + } + try { + Object parsed = JsonUtil.parse(goodsJson, List.class); + if (!(parsed instanceof List rows)) { + return; + } + for (Object row : rows) { + if (!(row instanceof Map goods)) { + continue; + } + addCargoToken(names, goods.get("cargoName") == null ? null : String.valueOf(goods.get("cargoName")), false); + addCargoToken(types, goods.get("cargoType") == null ? null : String.valueOf(goods.get("cargoType")), false); + } + } catch (RuntimeException ignored) { + // 货物清单异常时保留明细上已有的货物名称。 + } + } + + private void addCargoToken(Set target, String raw, boolean splitJoined) { + if (Func.isEmpty(raw)) { + return; + } + String[] parts = splitJoined ? raw.split("[,,]") : new String[]{raw}; + for (String part : parts) { + String token = part.trim(); + if (token.isEmpty() || "手工调差".equals(token)) { + continue; + } + target.add(token); + } + } + + private String joinCargo(Set values) { + String joined = String.join(",", values); + return joined.length() > 500 ? joined.substring(0, 500) : joined; } private void copyPreDetailFees(PreSettlementDetail source, FormalSettlementDetail target) { @@ -1718,9 +2076,16 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl 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()) @@ -1730,6 +2095,10 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl) null); + } + + private PaymentSummary calculatePaymentSummary(FormalSettlement settlement, Map writeOffAmounts) { List relations = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getFormalSettlementId, settlement.getId()) @@ -1747,7 +2116,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptClaimSettlement::getFormalSettlementId, settlement.getId()) - .eq(ReceiptClaimSettlement::getStatus, 1)) + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)) .stream().map(ReceiptClaimSettlement::getAllocatedReceiptAmount).map(this::money) .reduce(BigDecimal.ZERO, BigDecimal::add); } @@ -1815,22 +2192,91 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relations, List directApplications, Map relationStatuses, - List sources, BigDecimal receiptClaimPaid) { + List sources, BigDecimal receiptClaimPaid, + Map 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 listAdvancePayments(List sources, + List writeOffs) { + List records = new ArrayList<>(); + List preSettlementIds = sources.stream().map(FormalSettlementSource::getPreSettlementId) + .filter(Objects::nonNull).distinct().toList(); + if (!preSettlementIds.isEmpty()) { + List advances = this.preSettlementAdvanceMapper.selectList( + Wrappers.lambdaQuery() + .eq(PreSettlementAdvance::getIsDeleted, 0) + .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED) + .in(PreSettlementAdvance::getPreSettlementId, preSettlementIds)); + // 进度预付行的展示时间取底层付款申请创建时间(advanceNo 即付款申请单号) + List advanceNos = advances.stream().map(PreSettlementAdvance::getAdvanceNo) + .filter(Func::isNotEmpty).distinct().toList(); + Map applicationTimes = advanceNos.isEmpty() ? Map.of() + : this.paymentApplicationMapper.selectList(Wrappers.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 applicationIds = writeOffs.stream() + .map(SettlementProjectAdvanceWriteOff::getPaymentApplicationId) + .filter(Objects::nonNull).distinct().toList(); + Map 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 ""; @@ -1868,6 +2314,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() + .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("仅草稿或已驳回的正式结算单允许编辑"); @@ -1883,14 +2337,77 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl changes = new java.util.ArrayList<>(); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + if (creating) { + changes.add("新增合计费用项【" + row.getFeeItem() + "】金额【" + money(row.getSettlementAmount()) + "】"); + beforeData.put("费用类型", ""); + beforeData.put("费用项", ""); + beforeData.put("调整金额", BigDecimal.ZERO.setScale(2)); + beforeData.put("结算金额", BigDecimal.ZERO.setScale(2)); + beforeData.put("备注", ""); + afterData.put("费用类型", row.getFeeType()); + afterData.put("费用项", row.getFeeItem()); + afterData.put("调整金额", money(row.getAdjustAmount())); + afterData.put("结算金额", money(row.getSettlementAmount())); + afterData.put("备注", row.getRemark()); + saveChange(settlementId, "合计费用项", row.getLineNo(), "新增", String.join(";", changes), "", + beforeData, afterData); + return; + } + appendTextChange(changes, beforeData, afterData, "费用类型", beforeFeeType, row.getFeeType()); + appendTextChange(changes, beforeData, afterData, "费用项", beforeFeeItem, row.getFeeItem()); + appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount()); + appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount()); + appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark()); + if (changes.isEmpty()) { + return; + } + saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "", + beforeData, afterData); + } + + private void appendChange(List changes, Map beforeData, Map afterData, + String fieldName, BigDecimal before, BigDecimal after) { + BigDecimal oldValue = money(before); + BigDecimal newValue = money(after); + if (oldValue.compareTo(newValue) != 0) { + changes.add("【" + fieldName + "】从【" + oldValue + "】调整为【" + newValue + "】"); + beforeData.put(fieldName, oldValue); + afterData.put(fieldName, newValue); + } + } + + private void appendTextChange(List changes, Map beforeData, Map afterData, + String fieldName, String before, String after) { + String oldValue = before == null ? "" : before; + String newValue = after == null ? "" : after; + if (!Objects.equals(oldValue, newValue)) { + changes.add("【" + fieldName + "】从【" + oldValue + "】调整为【" + newValue + "】"); + beforeData.put(fieldName, oldValue); + afterData.put(fieldName, newValue); + } + } + private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType, String content, String reason) { + saveChange(settlementId, changeType, lineNo, operationType, content, reason, null, null); + } + + private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType, + String content, String reason, Map beforeData, Map afterData) { FormalSettlementChangeRecord record = new FormalSettlementChangeRecord(); record.setFormalSettlementId(settlementId); record.setChangeType(changeType); record.setLineNo(lineNo); record.setOperationType(operationType); record.setChangeContent(content); + record.setBeforeData(beforeData == null ? null : JsonUtil.toJson(beforeData)); + record.setAfterData(afterData == null ? null : JsonUtil.toJson(afterData)); record.setChangeReason(reason); record.setOperatorName(Func.isEmpty(AuthUtil.getUserName()) ? "system" : AuthUtil.getUserName()); record.setChangeTime(LocalDateTime.now()); @@ -1913,11 +2430,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(FormalSettlementPayment::getPaymentNo, prefix)); - return prefix + String.format("%04d", count + 1); + Long maxSequence = paymentMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1); } private BigDecimal money(BigDecimal value) { return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java index 99d8f77a..4459618e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java @@ -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; @@ -97,6 +95,7 @@ import org.springblade.transport.pojo.vo.InvoiceApplicationSheetVO; import org.springblade.transport.pojo.vo.InvoiceApplicationVO; import org.springblade.transport.service.IInvoiceApplicationService; import org.springblade.transport.service.IKingdeeBillLockService; +import org.springblade.transport.service.KingdeeSyncContext; import org.springblade.transport.support.KingdeeApBillSyncSupport; import org.springblade.transport.support.KingdeeArBillSyncSupport; import org.springblade.transport.support.KingdeeSyncLockSupport; @@ -700,7 +699,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl items) { BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO(); // ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ==================== - // 【必填】组织.编码:申请部门 deptId → 金蝶组织编码 - param.setOrgidNumber(dept.getKingdeeDeptCode()); - // 【必填】申请部门.编码:同上 - param.setAwrpOrgfieldNumber(dept.getKingdeeDeptCode()); + // 【必填】组织.编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码 + param.setOrgidNumber(orgNumber); + // 【必填】申请部门.编码:项目业务部门金蝶编码(与付款申请单 awrp_department_number 同源) + param.setAwrpOrgfieldNumber(deptNumber); // 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号) param.setBillno(billNo); // 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss @@ -1665,6 +1665,14 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() + .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("当前状态不可编辑"); @@ -1675,10 +1683,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(InvoiceApplication::getApplicationNo, prefix)) + 1); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } private String normalizeDepartmentEmails(String value, List invoiceInfos) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index 71da584e..bb136e21 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -63,6 +63,7 @@ import org.springblade.transport.mapper.FormalSettlementMapper; import org.springblade.transport.mapper.InvoiceReceiptMapper; import org.springblade.transport.mapper.InvoiceReceiptRecordMapper; import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper; +import org.springblade.transport.mapper.CustomerInvoiceInfoMapper; import org.springblade.transport.mapper.KingdeeInvoicePoolMapper; import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; @@ -70,6 +71,7 @@ import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.entity.CustomerContact; +import org.springblade.transport.pojo.entity.CustomerInvoiceInfo; import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.FormalSettlementDetail; import org.springblade.transport.pojo.entity.InvoiceReceipt; @@ -88,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; @@ -146,8 +149,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl selectLocalPoolPage(KingdeeInvoicePoolQueryDTO query, long current, long size) { - return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId()); + return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId(), + bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly()); } /** @@ -604,12 +611,16 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl 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 + " 笔"; @@ -670,7 +681,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl kingdeeRows, Map apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) { - InvoiceReceipt receipt = plan.receipt(); + // 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启) + InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.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())) { @@ -761,6 +786,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl invoiceTypes = this.poolInvoiceTypeCodes(); + // 客商单位过滤集:开关开启时一次性载入有效客商发票信息税号(销/购方税号都须命中才入镜像),整轮共享避免逐页查询 + Set customerTaxNos = bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly() ? this.customerInvoiceTaxNos() : null; SyncStats stats = new SyncStats(); - log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},单页 {} 条最多 {} 页(触顶自动二分切片)", + log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},客商单位过滤:{},单页 {} 条最多 {} 页(触顶自动二分切片)", windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER), - invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, POOL_PAGE_SIZE, MAX_POOL_PAGES); + invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, customerTaxNos != null ? "开启" : "关闭", + POOL_PAGE_SIZE, MAX_POOL_PAGES); if (invoiceTypes.isEmpty()) { - this.syncWindowSlice(windowStart, windowEnd, null, stats); + this.syncWindowSlice(windowStart, windowEnd, null, customerTaxNos, stats); } else { // 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度 for (String invoiceType : invoiceTypes) { - this.syncWindowSlice(windowStart, windowEnd, invoiceType, stats); + this.syncWindowSlice(windowStart, windowEnd, invoiceType, customerTaxNos, stats); } } String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取" - + stats.pulledCount + "张发票,镜像新增" + stats.created + "更新" + stats.updated + "无变化" + + stats.pulledCount + "张发票" + + (stats.customerFiltered > 0 ? "(客商单位过滤" + stats.customerFiltered + "张)" : "") + + ",镜像新增" + stats.created + "更新" + stats.updated + "无变化" + stats.mirrorUnchanged + ",补绑" + stats.bound + ",登记无变化跳过" + stats.unchanged + ",失败" + stats.failed + ",耗时" + (System.currentTimeMillis() - startMillis) + "ms"; log.info("金蝶全票池同步结束,{}", summary); @@ -1284,6 +1333,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛, * 单片 500 条以内 1 页即短页终止) * - * @param sliceStart 切片起点(含) - * @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取) - * @param stats 整轮统计累加器 + * @param sliceStart 切片起点(含) + * @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取) + * @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享) + * @param stats 整轮统计累加器 */ - private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, SyncStats stats) { + private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, + Set customerTaxNos, SyncStats stats) { // 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏 if (!sliceStart.isBefore(sliceEnd)) { log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd); @@ -1325,6 +1378,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl this.bothUnitsRegistered(row, customerTaxNos)).toList(); + stats.customerFiltered += rawPageSize - rows.size(); + } BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows); stats.created += upsertResult.created(); stats.updated += upsertResult.updated(); @@ -1336,8 +1396,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl customerInvoiceTaxNos() { + return customerInvoiceInfoMapper.selectList(Wrappers.lambdaQuery() + .eq(CustomerInvoiceInfo::getStatus, 1) + .eq(CustomerInvoiceInfo::getIsDeleted, 0)).stream() + .map(CustomerInvoiceInfo::getTaxNo) + .filter(StringUtil::isNotBlank) + .collect(Collectors.toSet()); + } + + /** + * 开票单位(销方)与受票单位(购方)税号是否均已登记在客商发票信息中;税号缺失视为未登记(开关开启时不入镜像) + */ + private boolean bothUnitsRegistered(BfiInvoicePoolVO row, Set customerTaxNos) { + return StringUtil.isNotBlank(row.getSalerTaxNo()) && StringUtil.isNotBlank(row.getBuyerTaxNo()) + && customerTaxNos.contains(row.getSalerTaxNo()) && customerTaxNos.contains(row.getBuyerTaxNo()); + } + /** 已登记票跟随刷新结果统计(绑定/刷新/无变化/失败) */ private record RefreshResult(int bound, int unchanged, int failed) { } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java index c39e563c..02aefab7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java @@ -251,6 +251,28 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl lockedRecords = this.list(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getSourceBillType, sourceBillType) + .eq(KingdeeBillLock::getSourceBillId, sourceBillId) + .eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED)); + if (Func.isEmpty(lockedRecords)) { + return 0; + } + LocalDateTime unlockTime = LocalDateTime.now(); + for (KingdeeBillLock lockRecord : lockedRecords) { + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED); + lockRecord.setOperationType(KingdeeLockConstant.OPERATION_ADJUST_REPUSH); + lockRecord.setUnlockTime(unlockTime); + lockRecord.setNotifyOperator(Func.isEmpty(operator) ? "system" : operator); + } + this.updateBatchById(lockedRecords); + log.info("结算调整重推解锁旧锁:源单[{}:{}],解锁{}张,操作人={}", sourceBillType, sourceBillId, lockedRecords.size(), operator); + return lockedRecords.size(); + } + @Override public void checkEditable(String sourceBillType, Long sourceBillId) { long lockedCount = this.count(Wrappers.lambdaQuery() @@ -309,8 +331,11 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl "金蝶删单"; + case KingdeeLockConstant.OPERATION_ADJUST_REPUSH -> "结算调整重推"; + default -> "金蝶锁单"; + }); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index 38057c1a..0f70c1a2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -72,6 +72,7 @@ import org.springblade.transport.mapper.PreSettlementMapper; import org.springblade.transport.mapper.ProjectApplyMapper; import org.springblade.transport.mapper.TemporaryCreditLimitMapper; import org.springblade.transport.pojo.dto.PaymentApplicationInvoiceRequest; +import org.springblade.transport.pojo.dto.PaymentApplicationSettlementLineRequest; import org.springblade.transport.pojo.dto.PaymentApplicationRecordRequest; import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest; import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest; @@ -116,10 +117,13 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -257,10 +261,20 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(org.springblade.transport.pojo.entity.PaymentApplicationRecord::getPaymentApplicationId, id) .orderByDesc(org.springblade.transport.pojo.entity.PaymentApplicationRecord::getCreateTime))); - vo.setSettlements(settlementRelationMapper.selectList(Wrappers.lambdaQuery() + List settlements = settlementRelationMapper.selectList(Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getPaymentApplicationId, id) .eq(PaymentApplicationSettlement::getIsDeleted, 0) - .orderByAsc(PaymentApplicationSettlement::getCreateTime))); + .orderByAsc(PaymentApplicationSettlement::getCreateTime)); + for (PaymentApplicationSettlement relation : settlements) { + if (relation.getPreSettlementId() != null) { + relation.setPayableAmount(payableOfPreSettlement(relation.getPreSettlementId(), relation.getSettlementAmount(), id) + .setScale(2, RoundingMode.HALF_UP)); + } else if (relation.getFormalSettlementId() != null) { + relation.setPayableAmount(payableOfFormalSettlement(relation.getFormalSettlementId(), relation.getSettlementAmount(), id) + .setScale(2, RoundingMode.HALF_UP)); + } + } + vo.setSettlements(settlements); return vo; } @@ -307,11 +321,16 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl preSettlementIds = resolvePreSettlementIds(request); + if (request.getPreSettlementId() == null && !preSettlementIds.isEmpty()) { + request.setPreSettlementId(preSettlementIds.get(0)); + } fillReference(entity, request); List formalSettlements = resolveFormalSettlements(request, entity); if (formalSettlements.size() > 1) fillFormalAggregateReference(entity, formalSettlements); + List preSettlements = resolvePreSettlements(preSettlementIds); + if (preSettlements.size() > 1) fillPreAggregateReference(entity, preSettlements); fillBillLedger(entity, request); validateQuota(entity); saveOrUpdate(entity); + // not_null 更新策略不会把 settlementId 的 null 写回库,进度预付需显式清掉误存的正式结算单 ID + clearDetachedFormalReference(entity); + // 汇票付款在生成申请时即占用可用余额;编辑时按差额调整,避免重复扣减 + syncBillOccupation(entity); invoiceMapper.delete(Wrappers.lambdaQuery() .eq(PaymentApplicationInvoice::getPaymentApplicationId, entity.getId())); int lineNo = 1; @@ -395,12 +426,22 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0 ? "matched" : "unmatched"); entity.setPaidAmount(paidAmount); updateById(entity); - saveSettlementRelations(entity, formalSettlements); + saveSettlementRelations(entity, formalSettlements, request.getSettlementLines()); + savePreSettlementRelations(entity, preSettlements, request.getSettlementLines()); return entity.getId(); } @Override @Transactional(rollbackFor = Exception.class) - public void removeDraft(Long id) { PaymentApplication entity = editable(id); invoiceMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationInvoice::getPaymentApplicationId, id)); recordMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationRecord::getPaymentApplicationId, id)); settlementRelationMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationSettlement::getPaymentApplicationId, id)); removeById(entity); } + public void removeDraft(Long id) { + PaymentApplication entity = editable(id); + if (isBillPayment(entity.getPaymentMethod())) { + releaseBillBalanceIfOccupied(entity); + } + invoiceMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationInvoice::getPaymentApplicationId, id)); + recordMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationRecord::getPaymentApplicationId, id)); + settlementRelationMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationSettlement::getPaymentApplicationId, id)); + removeById(entity); + } @Override @Transactional(rollbackFor = Exception.class) public void submit(PaymentApplicationStatusRequest request) { @@ -415,13 +456,33 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl writeOffSettlementIds = this.projectAdvanceWriteOffService.deleteByPaymentApplicationIds(List.of(entity.getId())); + if (!writeOffSettlementIds.isEmpty()) { + formalSettlementService.refreshPaymentSummaries(writeOffSettlementIds); + } refreshFormalSettlement(entity); } @@ -443,7 +511,10 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl relations = settlementRelationMapper.selectList( + Wrappers.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.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.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(money(entity.getAppliedAmount())); - advance.setBillStatus("paid"); + 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.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); + } + + /** + * 联动作废付款申请关联的预结算预付记录(付款申请作废、金蝶反审核解锁场景): + * 仅清理提前建档的未付记录,已付款记录不联动(付款完成的事实不回退),避免残留记录阻断预结算作废 + */ + private void voidPreSettlementAdvance(PaymentApplication entity) { + if (entity.getPreSettlementId() == null) return; + if (!List.of("progress_advance", "settlement_payment").contains(entity.getPaymentType())) return; + // 多选预结算单的预付记录按关联行逐单作废;单选回退单据上的预结算 + List preSettlementIds = new ArrayList<>(List.of(entity.getPreSettlementId())); + settlementRelationMapper.selectList(Wrappers.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.lambdaUpdate() + .in(PreSettlementAdvance::getPreSettlementId, preSettlementIds) + .eq(PreSettlementAdvance::getAdvanceNo, entity.getPaymentNo()) + .eq(PreSettlementAdvance::getIsDeleted, 0) + .ne(PreSettlementAdvance::getBillStatus, "paid") + .set(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)); + preSettlementIds.forEach(this::refreshPreSettlementAdvanceSummary); } private BigDecimal findPreSettlementPaid(Long preSettlementId) { @@ -1683,11 +1830,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0)) throw new ServiceException("付款比例必须在0-100之间"); if (request.getAppliedAmount() == null || request.getAppliedAmount().compareTo(BigDecimal.ZERO) <= 0) throw new ServiceException("申请付款金额必须大于0"); if (!"project_advance".equals(request.getPaymentType()) && request.getSettlementId() == null - && request.getPreSettlementId() == null && Func.isEmpty(request.getSettlementIds())) throw new ServiceException("非项目预付必须关联结算单"); + && request.getPreSettlementId() == null && Func.isEmpty(request.getSettlementIds()) + && Func.isEmpty(request.getPreSettlementIds())) throw new ServiceException("非项目预付必须关联结算单"); if ("project_advance".equals(request.getPaymentType()) && request.getProjectId() == null) throw new ServiceException("项目预付必须选择所属项目"); - if ("progress_advance".equals(request.getPaymentType()) && request.getPreSettlementId() == null) throw new ServiceException("进度预付必须关联预结算单"); + if ("progress_advance".equals(request.getPaymentType()) && request.getPreSettlementId() == null + && Func.isEmpty(request.getPreSettlementIds())) throw new ServiceException("进度预付必须关联预结算单"); if (TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) && request.getSettlementId() == null - && Func.isEmpty(request.getSettlementIds()) && request.getPreSettlementId() == null) { + && Func.isEmpty(request.getSettlementIds()) && request.getPreSettlementId() == null + && Func.isEmpty(request.getPreSettlementIds())) { throw new ServiceException("尾款付款必须关联正式结算单或预结算单"); } } @@ -1744,6 +1894,75 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(PaymentApplication::getId, entity.getId()) + .setSql("settlement_id = null, settlement_no = null")); + entity.setSettlementId(null); + entity.setSettlementNo(null); + } + private void fillReference(PaymentApplication entity, PaymentApplicationSaveRequest request) { if ("project_advance".equals(request.getPaymentType())) { ProjectApply project = projectApplyMapper.selectById(request.getProjectId()); @@ -1752,23 +1971,64 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0 && money(entity.getPayableAmount()).compareTo(BigDecimal.ZERO) > 0) throw new ServiceException("申请付款金额不能超过可付款金额"); } + private PreSettlement requireApprovedPreSettlement(Long preSettlementId) { + PreSettlement source = preSettlementMapper.selectById(preSettlementId); + if (source == null || Objects.equals(source.getIsDeleted(), 1)) { + throw new ServiceException("预结算单不存在"); + } + if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) { + throw new ServiceException("只能选择审批通过、未作废的预结算单"); + } + return source; + } + + private void fillPreSettlementReference(PaymentApplication entity, PreSettlement source) { + entity.setPreSettlementId(source.getId()); + entity.setPreSettlementNo(source.getPreSettlementNo()); + entity.setSettlementId(null); + entity.setSettlementNo(null); + entity.setProjectId(source.getProjectId()); + entity.setProjectName(source.getProjectName()); + entity.setDeptId(source.getDeptId()); + entity.setDeptName(source.getDeptName()); + entity.setContractId(source.getContractId()); + entity.setContractNo(source.getContractNo()); + entity.setContractName(source.getContractName()); + entity.setPayerName(source.getPayerName()); + entity.setPayeeName(source.getPayeeName()); + entity.setSettlementAmount(source.getSettlementAmount()); + entity.setPayableAmount(payableOfPreSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); + entity.setBillType("预结算单"); + } + private List resolveFormalSettlements(PaymentApplicationSaveRequest request, PaymentApplication entity) { if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType())) return List.of(); List ids = request.getSettlementIds() == null ? List.of() : request.getSettlementIds(); @@ -1806,19 +2066,33 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl settlements) { + private void saveSettlementRelations(PaymentApplication entity, List settlements, + List lines) { if (settlements.isEmpty()) return; settlementRelationMapper.delete(Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId())); + boolean customAmounts = settlements.size() > 1 && lines != null && !lines.isEmpty(); BigDecimal totalBase = settlements.stream() .map(item -> payableOfFormalSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal remainingApplied = money(entity.getAppliedAmount()); BigDecimal remainingPaid = money(entity.getPaidAmount()); + if (customAmounts) { + BigDecimal lineTotal = BigDecimal.ZERO; + for (FormalSettlement settlement : settlements) { + BigDecimal payable = payableOfFormalSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); + BigDecimal applied = this.specifiedAppliedAmount(lines, settlement.getId(), null); + this.assertAppliedWithinPayable(settlement.getFormalSettlementNo(), applied, payable); + lineTotal = lineTotal.add(applied); + } + this.assertLineTotalMatches(entity, lineTotal); + } for (int index = 0; index < settlements.size(); index++) { FormalSettlement settlement = settlements.get(index); BigDecimal base = payableOfFormalSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); - BigDecimal applied = index == settlements.size() - 1 ? remainingApplied + BigDecimal applied = customAmounts + ? this.specifiedAppliedAmount(lines, settlement.getId(), null) + : index == settlements.size() - 1 || totalBase.compareTo(BigDecimal.ZERO) == 0 ? remainingApplied : money(entity.getAppliedAmount()).multiply(base).divide(totalBase, 2, RoundingMode.DOWN); BigDecimal paid = index == settlements.size() - 1 ? remainingPaid : money(entity.getPaidAmount()).multiply(applied).divide(money(entity.getAppliedAmount()).max(BigDecimal.ONE), 2, RoundingMode.DOWN); @@ -1830,6 +2104,156 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl resolvePreSettlementIds(PaymentApplicationSaveRequest request) { + if (!List.of("progress_advance", TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT).contains(request.getPaymentType())) { + return List.of(); + } + boolean formalSelected = TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) + && ((request.getSettlementIds() != null && request.getSettlementIds().stream().anyMatch(Objects::nonNull)) + || request.getSettlementId() != null) + && Func.isEmpty(request.getPreSettlementIds()); + if (formalSelected) { + return List.of(); + } + List ids = request.getPreSettlementIds() == null ? List.of() : request.getPreSettlementIds(); + ids = ids.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty() && request.getPreSettlementId() != null) { + return List.of(request.getPreSettlementId()); + } + return ids; + } + + private boolean hasMultiplePreReferences(PaymentApplicationSaveRequest request) { + return request.getPreSettlementIds() != null + && request.getPreSettlementIds().stream().filter(Objects::nonNull).distinct().count() > 1; + } + + private List resolvePreSettlements(List ids) { + if (ids.isEmpty()) { + return List.of(); + } + List settlements = ids.stream().map(preSettlementMapper::selectById).toList(); + if (settlements.stream().anyMatch(item -> item == null || Objects.equals(item.getIsDeleted(), 1) + || !TransportBusiConstant.APPROVED.equals(item.getApprovalStatus()))) { + throw new ServiceException("只能选择审批通过、未作废的预结算单"); + } + if (settlements.size() > 1) { + Long contractId = settlements.get(0).getContractId(); + if (contractId == null || settlements.stream().anyMatch(item -> !Objects.equals(contractId, item.getContractId()))) { + throw new ServiceException("多选预结算单必须属于同一合同"); + } + } + return settlements; + } + + private void fillPreAggregateReference(PaymentApplication entity, List settlements) { + BigDecimal settlementAmount = settlements.stream().map(PreSettlement::getSettlementAmount).map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); + BigDecimal payableAmount = settlements.stream() + .map(item -> payableOfPreSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); + if (money(entity.getAppliedAmount()).compareTo(payableAmount) > 0) { + throw new ServiceException("申请付款金额不能超过所选预结算单可付款金额合计"); + } + PreSettlement first = settlements.get(0); + String preSettlementNo = settlements.stream().map(PreSettlement::getPreSettlementNo).filter(Objects::nonNull) + .collect(Collectors.joining("、")); + entity.setPreSettlementId(first.getId()); + entity.setPreSettlementNo(preSettlementNo.length() > 500 ? preSettlementNo.substring(0, 500) : preSettlementNo); + entity.setSettlementId(null); + entity.setSettlementNo(null); + entity.setSettlementAmount(settlementAmount); + entity.setPayableAmount(payableAmount); + entity.setBillType("预结算单"); + } + + private void savePreSettlementRelations(PaymentApplication entity, List settlements, + List lines) { + settlementRelationMapper.delete(Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()) + .isNotNull(PaymentApplicationSettlement::getPreSettlementId)); + List targetSettlements = settlements; + if (targetSettlements.isEmpty() && entity.getPreSettlementId() != null) { + PreSettlement single = preSettlementMapper.selectById(entity.getPreSettlementId()); + if (single != null && !Objects.equals(single.getIsDeleted(), 1)) { + targetSettlements = List.of(single); + } + } + if (targetSettlements.isEmpty()) { + return; + } + // 单选/多选均落关联行,生成即占用可付款额度(删除草稿或审核不通过后释放) + boolean customAmounts = targetSettlements.size() > 1 && lines != null && !lines.isEmpty(); + BigDecimal totalBase = targetSettlements.stream() + .map(item -> payableOfPreSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal remainingApplied = money(entity.getAppliedAmount()); + if (customAmounts) { + BigDecimal lineTotal = BigDecimal.ZERO; + for (PreSettlement settlement : targetSettlements) { + BigDecimal payable = payableOfPreSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); + BigDecimal applied = this.specifiedAppliedAmount(lines, null, settlement.getId()); + this.assertAppliedWithinPayable(settlement.getPreSettlementNo(), applied, payable); + lineTotal = lineTotal.add(applied); + } + this.assertLineTotalMatches(entity, lineTotal); + } + for (int index = 0; index < targetSettlements.size(); index++) { + PreSettlement settlement = targetSettlements.get(index); + BigDecimal base = payableOfPreSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); + BigDecimal applied = customAmounts + ? this.specifiedAppliedAmount(lines, null, settlement.getId()) + : index == targetSettlements.size() - 1 || totalBase.compareTo(BigDecimal.ZERO) == 0 + ? remainingApplied + : money(entity.getAppliedAmount()).multiply(base).divide(totalBase, 2, RoundingMode.DOWN); + PaymentApplicationSettlement relation = new PaymentApplicationSettlement(); + relation.setPaymentApplicationId(entity.getId()); + relation.setPreSettlementId(settlement.getId()); + relation.setPreSettlementNo(settlement.getPreSettlementNo()); + relation.setSettlementAmount(money(settlement.getSettlementAmount()).setScale(2, RoundingMode.HALF_UP)); + relation.setAppliedAmount(applied.setScale(2, RoundingMode.HALF_UP)); + relation.setPaidAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); + settlementRelationMapper.insert(relation); + remainingApplied = remainingApplied.subtract(applied); + } + } + + private BigDecimal specifiedAppliedAmount(List lines, + Long formalSettlementId, Long preSettlementId) { + if (lines == null) { + throw new ServiceException("请填写每张结算单的付款金额"); + } + for (PaymentApplicationSettlementLineRequest line : lines) { + if (line == null) { + continue; + } + boolean matched = formalSettlementId != null + ? Objects.equals(formalSettlementId, line.getFormalSettlementId()) + : Objects.equals(preSettlementId, line.getPreSettlementId()); + if (matched && line.getAppliedAmount() != null) { + return money(line.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); + } + } + throw new ServiceException("请填写每张结算单的付款金额"); + } + + private void assertAppliedWithinPayable(String settlementNo, BigDecimal appliedAmount, BigDecimal payableAmount) { + if (money(appliedAmount).compareTo(BigDecimal.ZERO) < 0) { + throw new ServiceException("付款金额不能小于0"); + } + if (money(appliedAmount).compareTo(money(payableAmount)) > 0) { + String label = Func.isEmpty(settlementNo) ? "结算单" : settlementNo; + throw new ServiceException(label + "付款金额不能超过可付款金额"); + } + } + + private void assertLineTotalMatches(PaymentApplication entity, BigDecimal lineTotal) { + BigDecimal appliedAmount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); + if (lineTotal.setScale(2, RoundingMode.HALF_UP).compareTo(appliedAmount) != 0) { + throw new ServiceException("申请付款金额须等于各结算单付款金额合计"); + } + } + private void validateQuota(PaymentApplication entity) { if (!"project_advance".equals(entity.getPaymentType())) return; BigDecimal appliedAmount = money(entity.getAppliedAmount()); @@ -1867,10 +2291,24 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl wrapper) { - return list(wrapper.ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() + return list(wrapper).stream() + .filter(item -> !releasedOccupiedPayment(item)) .map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } + /** + * 已释放占用的付款申请:作废、审核不通过(驳回)不再占用可付款金额; + * 删除草稿由逻辑删除自然释放。生成草稿/审批中/审批通过均占用。 + */ + private boolean releasedOccupiedPayment(PaymentApplication payment) { + if (payment == null || Objects.equals(payment.getIsDeleted(), 1)) { + return true; + } + String status = payment.getApprovalStatus(); + return TransportBusiConstant.VOIDED.equals(status) + || TransportBusiConstant.RETURNED.equals(status); + } + private BigDecimal cumulativeAppliedAmount(String paymentType, Long referenceId, Long excludeId) { if ("progress_advance".equals(paymentType)) { return cumulativePreSettlementApplied(referenceId, excludeId); @@ -1881,28 +2319,158 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl paymentIds = new LinkedHashSet<>(); + if (preSettlementReference) { + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPreSettlementId, referenceId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + for (PaymentApplicationSettlement relation : relations) { + if (relation.getPaymentApplicationId() != null) { + paymentIds.add(relation.getPaymentApplicationId()); + } + } + list(Wrappers.lambdaQuery() + .eq(PaymentApplication::getPreSettlementId, referenceId) + .ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)) + .forEach(payment -> paymentIds.add(payment.getId())); + } else { + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getFormalSettlementId, referenceId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + for (PaymentApplicationSettlement relation : relations) { + if (relation.getPaymentApplicationId() != null) { + paymentIds.add(relation.getPaymentApplicationId()); + } + } + list(Wrappers.lambdaQuery() + .eq(PaymentApplication::getSettlementId, referenceId) + .ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)) + .forEach(payment -> paymentIds.add(payment.getId())); + } + if (paymentIds.isEmpty()) { + return 1; + } + long priorCount = listByIds(paymentIds.stream().toList()).stream() + .filter(payment -> !releasedOccupiedPayment(payment)) + .filter(payment -> !Objects.equals(payment.getId(), excludeId)) + .count(); + return (int) priorCount + 1; + } + private BigDecimal cumulativeFormalSettlementApplied(Long settlementId, Long excludeId) { - return sumApplied(Wrappers.lambdaQuery() + // 多选正式结算时按关联行累计占用;单选兼容主表 settlementId + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getFormalSettlementId, settlementId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + Set coveredPaymentIds = new HashSet<>(); + BigDecimal relationAmount = BigDecimal.ZERO; + if (!relations.isEmpty()) { + Map payments = listByIds(relations.stream() + .map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull).distinct().toList()) + .stream().collect(Collectors.toMap(PaymentApplication::getId, item -> item, (left, right) -> left)); + for (PaymentApplicationSettlement relation : relations) { + PaymentApplication payment = payments.get(relation.getPaymentApplicationId()); + if (releasedOccupiedPayment(payment) || Objects.equals(payment.getId(), excludeId)) { + if (payment != null) { + coveredPaymentIds.add(payment.getId()); + } + continue; + } + coveredPaymentIds.add(payment.getId()); + relationAmount = relationAmount.add(money(relation.getAppliedAmount())); + } + } + BigDecimal legacyAmount = sumApplied(Wrappers.lambdaQuery() .eq(PaymentApplication::getSettlementId, settlementId) - .ne(excludeId != null, PaymentApplication::getId, excludeId)); + .ne(excludeId != null, PaymentApplication::getId, excludeId) + .notIn(!coveredPaymentIds.isEmpty(), PaymentApplication::getId, coveredPaymentIds)); + return relationAmount.add(legacyAmount); } private BigDecimal cumulativePreSettlementApplied(Long preSettlementId, Long excludeId) { - // 进度预付、尾款付款关联同一预结算单时累计占用可付款额度 - return sumApplied(Wrappers.lambdaQuery() + // 进度预付、尾款付款关联同一预结算单时累计占用可付款额度;按关联行申请金额累计,兼容历史仅写主表的单据 + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPreSettlementId, preSettlementId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + Set coveredPaymentIds = new HashSet<>(); + BigDecimal relationAmount = BigDecimal.ZERO; + if (!relations.isEmpty()) { + Map payments = listByIds(relations.stream() + .map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull).distinct().toList()) + .stream().collect(Collectors.toMap(PaymentApplication::getId, item -> item, (left, right) -> left)); + for (PaymentApplicationSettlement relation : relations) { + PaymentApplication payment = payments.get(relation.getPaymentApplicationId()); + if (releasedOccupiedPayment(payment) || Objects.equals(payment.getId(), excludeId)) { + if (payment != null) { + coveredPaymentIds.add(payment.getId()); + } + continue; + } + coveredPaymentIds.add(payment.getId()); + relationAmount = relationAmount.add(money(relation.getAppliedAmount())); + } + } + BigDecimal legacyAmount = list(Wrappers.lambdaQuery() .eq(PaymentApplication::getPreSettlementId, preSettlementId) - .ne(excludeId != null, PaymentApplication::getId, excludeId)); + .ne(excludeId != null, PaymentApplication::getId, excludeId) + .notIn(!coveredPaymentIds.isEmpty(), PaymentApplication::getId, coveredPaymentIds)).stream() + .filter(item -> !releasedOccupiedPayment(item)) + .map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); + return relationAmount.add(legacyAmount); } - /** 正式结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 */ + /** + * 正式结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 − 来源预结算进度预付占用。 + * 进度预付占用与正式结算台账 remainingPayable 口径对齐,避免转正式后仍可按全额申请尾款。 + */ private BigDecimal payableOfFormalSettlement(Long settlementId, BigDecimal settlementAmount, Long excludeId) { return money(settlementAmount) .subtract(cumulativeFormalSettlementApplied(settlementId, excludeId)) .subtract(projectAdvanceWriteOffService.sumBySettlement( TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settlementId)) + .subtract(formalSettlementAdvanceOccupied(settlementId)) .max(BigDecimal.ZERO); } + /** + * 正式结算来源预结算的进度预付占用合计:优先取来源行快照,快照为空时回退预结算实时已申请/已付。 + */ + private BigDecimal formalSettlementAdvanceOccupied(Long settlementId) { + if (settlementId == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + List sources = formalSettlementSourceMapper.selectList( + Wrappers.lambdaQuery() + .eq(FormalSettlementSource::getFormalSettlementId, settlementId) + .eq(FormalSettlementSource::getIsDeleted, 0)); + if (sources.isEmpty()) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + BigDecimal total = BigDecimal.ZERO; + for (FormalSettlementSource source : sources) { + BigDecimal occupied = money(source.getAdvanceAppliedAmount()).max(money(source.getAdvancePaidAmount())); + if (occupied.compareTo(BigDecimal.ZERO) <= 0 && source.getPreSettlementId() != null) { + occupied = findPreSettlementApplied(source.getPreSettlementId()) + .max(findPreSettlementPaid(source.getPreSettlementId())); + } + total = total.add(occupied); + } + return total; + } + /** 预结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 */ private BigDecimal payableOfPreSettlement(Long preSettlementId, BigDecimal settlementAmount, Long excludeId) { return money(settlementAmount) @@ -1919,7 +2487,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0) throw new ServiceException("申请付款金额超过汇票可用余额"); + BigDecimal available = money(ledger.getAvailableBalance()).add(occupiedAmount(paymentApplicationId, ledger.getId())); + if (money(amount).compareTo(available) > 0) throw new ServiceException("申请付款金额超过汇票可用余额"); if (!departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内"); } private void useBillBalance(PaymentApplication entity) { BillLedger ledger = lockedBill(entity.getBillLedgerId()); - validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true); - BillLedgerUsage exists = billLedgerUsageMapper.selectOne(Wrappers.lambdaQuery() - .eq(BillLedgerUsage::getPaymentApplicationId, entity.getId()).last("FOR UPDATE")); - if (exists != null) throw new ServiceException("该付款申请已生成汇票使用记录"); + validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null); + BillLedgerUsage exists = lockedBillUsage(entity.getId()); + if (exists != null && TransportBusiConstant.APPROVED.equals(exists.getUsageStatus())) { + throw new ServiceException("该付款申请已生成汇票使用记录"); + } BigDecimal amount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); - ledger.setAvailableBalance(money(ledger.getAvailableBalance()).subtract(amount)); + ledger.setAvailableBalance(money(ledger.getAvailableBalance()).subtract(amount).setScale(2, RoundingMode.HALF_UP)); + billLedgerMapper.updateById(ledger); + if (exists == null) { + BillLedgerUsage usage = new BillLedgerUsage(); + usage.setBillLedgerId(ledger.getId()); + usage.setPaymentApplicationId(entity.getId()); + usage.setApplicationNo(entity.getPaymentNo()); + usage.setUsedAmount(amount); + usage.setUseDeptId(entity.getDeptId()); + usage.setUseDeptName(entity.getDeptName()); + usage.setUsageStatus(TransportBusiConstant.APPROVED); + usage.setStatus(1); + billLedgerUsageMapper.insert(usage); + return; + } + exists.setBillLedgerId(ledger.getId()); + exists.setApplicationNo(entity.getPaymentNo()); + exists.setUsedAmount(amount); + exists.setUseDeptId(entity.getDeptId()); + exists.setUseDeptName(entity.getDeptName()); + exists.setUsageStatus(TransportBusiConstant.APPROVED); + billLedgerUsageMapper.updateById(exists); + } + + /** + * 汇票付款申请生成后占用汇票可用余额。同一申请重复保存时只调整差额; + * 更换汇票或改为非汇票付款时,先退回原占用。 + */ + private void syncBillOccupation(PaymentApplication entity) { + BillLedgerUsage usage = lockedBillUsage(entity.getId()); + boolean occupy = isBillPayment(entity.getPaymentMethod()) && entity.getBillLedgerId() != null; + if (!occupy) { + if (usage != null && TransportBusiConstant.APPROVED.equals(usage.getUsageStatus())) { + releaseUsage(usage); + } + return; + } + BigDecimal amount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); + if (usage != null && TransportBusiConstant.APPROVED.equals(usage.getUsageStatus()) + && Objects.equals(usage.getBillLedgerId(), entity.getBillLedgerId())) { + adjustOccupiedAmount(usage, entity, amount); + return; + } + if (usage != null && TransportBusiConstant.APPROVED.equals(usage.getUsageStatus())) { + releaseUsage(usage); + } + useBillBalance(entity); + } + + private void adjustOccupiedAmount(BillLedgerUsage usage, PaymentApplication entity, BigDecimal amount) { + BigDecimal delta = amount.subtract(money(usage.getUsedAmount())).setScale(2, RoundingMode.HALF_UP); + BillLedger ledger = lockedBill(usage.getBillLedgerId()); + if (delta.compareTo(BigDecimal.ZERO) > 0 && delta.compareTo(money(ledger.getAvailableBalance())) > 0) { + throw new ServiceException("申请付款金额超过汇票可用余额"); + } + BigDecimal available = money(ledger.getAvailableBalance()).subtract(delta).setScale(2, RoundingMode.HALF_UP); + if (available.compareTo(BigDecimal.ZERO) < 0) { + throw new ServiceException("申请付款金额超过汇票可用余额"); + } + BigDecimal faceAmount = money(ledger.getFaceAmount()); + if (available.compareTo(faceAmount) > 0) { + available = faceAmount; + } + ledger.setAvailableBalance(available); billLedgerMapper.updateById(ledger); - BillLedgerUsage usage = new BillLedgerUsage(); - usage.setBillLedgerId(ledger.getId()); - usage.setPaymentApplicationId(entity.getId()); usage.setApplicationNo(entity.getPaymentNo()); usage.setUsedAmount(amount); usage.setUseDeptId(entity.getDeptId()); usage.setUseDeptName(entity.getDeptName()); - usage.setUsageStatus(TransportBusiConstant.APPROVED); - usage.setStatus(1); - billLedgerUsageMapper.insert(usage); + billLedgerUsageMapper.updateById(usage); + } + + private BigDecimal occupiedAmount(Long paymentApplicationId, Long billLedgerId) { + if (paymentApplicationId == null || billLedgerId == null) { + return BigDecimal.ZERO; + } + BillLedgerUsage usage = activeBillUsage(paymentApplicationId); + if (usage == null || !Objects.equals(usage.getBillLedgerId(), billLedgerId)) { + return BigDecimal.ZERO; + } + return money(usage.getUsedAmount()); + } + + private BillLedgerUsage activeBillUsage(Long paymentApplicationId) { + if (paymentApplicationId == null) { + return null; + } + return billLedgerUsageMapper.selectOne(Wrappers.lambdaQuery() + .eq(BillLedgerUsage::getPaymentApplicationId, paymentApplicationId) + .eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED) + .last("limit 1")); + } + + private BillLedgerUsage lockedBillUsage(Long paymentApplicationId) { + if (paymentApplicationId == null) { + return null; + } + return billLedgerUsageMapper.selectOne(Wrappers.lambdaQuery() + .eq(BillLedgerUsage::getPaymentApplicationId, paymentApplicationId) + .last("limit 1 FOR UPDATE")); } private void releaseBillBalance(PaymentApplication entity) { @@ -1965,9 +2626,23 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() + .eq(BillLedgerUsage::getPaymentApplicationId, entity.getId()) + .eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED).last("FOR UPDATE")); + if (usage == null) { + return; + } + releaseUsage(usage); + } + + private void releaseUsage(BillLedgerUsage usage) { BillLedger ledger = lockedBill(usage.getBillLedgerId()); ledger.setAvailableBalance(money(ledger.getAvailableBalance()).add(money(usage.getUsedAmount())) - .min(money(ledger.getFaceAmount()))); + .min(money(ledger.getFaceAmount())).setScale(2, RoundingMode.HALF_UP)); billLedgerMapper.updateById(ledger); usage.setUsageStatus("released"); billLedgerUsageMapper.updateById(usage); @@ -2016,8 +2691,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery().likeRight(PaymentApplication::getPaymentNo, prefix)) + 1); } + /** + * 生成当日付款申请单号;按最大流水号 +1,且计入逻辑删除数据,避免撞 uk_payment_application_no + */ + private synchronized String nextNo() { + String prefix = "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); + } private BigDecimal nonNegative(BigDecimal value, String name) { if (value == null || value.compareTo(BigDecimal.ZERO) < 0) throw new ServiceException(name + "不能小于0"); return value; } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String limit(String value, int length) { if (value != null && value.length() > length) throw new ServiceException("备注不能超过" + length + "个字符"); return value; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java index de0b160e..2b98c34a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java @@ -85,7 +85,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -166,7 +165,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl categoryNames = DictBizCache.getList("fee_category").stream() .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first, LinkedHashMap::new)); - org.springblade.core.tool.api.R> response = sysClient.getFeeItems(); + org.springblade.core.tool.api.R> response = sysClient.getFeeItems(false); if (response == null || !response.isSuccess() || response.getData() == null) { throw new ServiceException("费用项基础档案读取失败,请稍后重试"); } @@ -187,6 +186,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl details = listDetails(id); + fillDetailBillingTypes(details); vo.setDetails(details); List detailIds = details.stream().map(PreSettlementDetail::getId).toList(); Map> feeMap = Func.isEmpty(detailIds) ? new HashMap<>() @@ -307,7 +307,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl sourcePage = sourceDetailMapper.selectPage( new Page<>(page.getCurrent(), page.getSize()), wrapper); Page> result = new Page<>(sourcePage.getCurrent(), sourcePage.getSize(), sourcePage.getTotal()); - result.setRecords(sourcePage.getRecords().stream().map(this::candidateMap).toList()); + List> records = sourcePage.getRecords().stream().map(this::candidateMap).toList(); + fillCandidateBillingTypes(records); + result.setRecords(records); return result; } @@ -461,6 +463,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl existingMap = detailFees(detail.getId()).stream() .collect(Collectors.toMap(PreSettlementDetailFee::getId, Function.identity())); if (request.getRows().size() != existingMap.size()) { @@ -636,7 +643,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl beforeFeeItems = parseFeeItems(fee.getFeeItemsJson()); fee.setTransportQuantity(nonNegative(requestRow.getTransportQuantity(), "运输总量")); fee.setMileage(nonNegative(requestRow.getMileage(), "里程")); @@ -649,7 +655,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl> records) { + if (records == null || records.isEmpty()) { + return; + } + List detailIds = records.stream() + .map(item -> Func.toLong(item.get("id"))) + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (detailIds.isEmpty()) { + return; + } + Map billingTypeMap = sourceFeeMapper.selectList( + Wrappers.lambdaQuery() + .in(ReceivablePayableCargoFee::getDetailId, detailIds) + .orderByAsc(ReceivablePayableCargoFee::getLineNo)) + .stream() + .filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) + .collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId, + Collectors.mapping(ReceivablePayableCargoFee::getBillingType, + Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new), + values -> String.join("、", values))))); + for (Map record : records) { + Long detailId = Func.toLong(record.get("id")); + record.put("billingType", billingTypeMap.getOrDefault(detailId, "")); + } + } + private void synchronizeDetails(PreSettlement settlement, List requestedIds, boolean allowSourceMismatch) { List distinctIds = requestedIds.stream().filter(Objects::nonNull).distinct().toList(); List existingDetails = listDetails(settlement.getId()); @@ -1001,13 +1039,30 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementSummaryFee::getPreSettlementId, settlementId) .eq(PreSettlementSummaryFee::getManualFlag, 0)); - int lineNo = 1; - for (Map.Entry entry : aggregates.entrySet()) { + // 尽量沿用已有系统归集行的序号,避免重建后与手工费用交错顺序被打乱。 + List> orderedAggregates = new ArrayList<>(aggregates.entrySet()); + orderedAggregates.sort((left, right) -> { + PreSettlementSummaryFee leftOld = existingGenerated.get(left.getKey()); + PreSettlementSummaryFee rightOld = existingGenerated.get(right.getKey()); + Integer leftLineNo = leftOld == null ? null : leftOld.getLineNo(); + Integer rightLineNo = rightOld == null ? null : rightOld.getLineNo(); + if (leftLineNo == null && rightLineNo == null) { + return 0; + } + if (leftLineNo == null) { + return 1; + } + if (rightLineNo == null) { + return -1; + } + return Integer.compare(leftLineNo, rightLineNo); + }); + for (Map.Entry entry : orderedAggregates) { String[] keyParts = entry.getKey().split("\\|", 2); PreSettlementSummaryFee old = existingGenerated.get(entry.getKey()); PreSettlementSummaryFee row = new PreSettlementSummaryFee(); row.setPreSettlementId(settlementId); - row.setLineNo(lineNo++); + row.setLineNo(old == null || old.getLineNo() == null ? Integer.MAX_VALUE : old.getLineNo()); row.setFeeType(keyParts[0]); row.setFeeItem(keyParts.length > 1 ? keyParts[1] : ""); // 原金额是明细首次生成时的快照,多次调整后不得被当前结算金额覆盖。 @@ -1023,7 +1078,14 @@ public class PreSettlementServiceImpl extends BaseServiceImpl requestRows) { - if (requestRows == null) return; + if (requestRows == null) { + return; + } + // 空列表表示沿用 rebuildSummaryFees 结果(如更换合同后),不得再按空请求删光合计行。 + if (requestRows.isEmpty()) { + renumberSummaryFees(settlementId); + return; + } Map> allowedManualFees = new LinkedHashMap<>(); if (requestRows.stream().anyMatch(row -> Integer.valueOf(1).equals(row.getManualFlag()))) { for (Map option : feeOptions()) { @@ -1043,6 +1105,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl availableGeneratedRows = new ArrayList<>(existingGeneratedRows); Set retainedManualIds = new LinkedHashSet<>(); Set retainedGeneratedIds = new LinkedHashSet<>(); + int lineNo = 1; for (PreSettlementSaveRequest.SummaryFee requestRow : requestRows) { if (Integer.valueOf(1).equals(requestRow.getManualFlag())) { String feeType = requiredText(requestRow.getFeeType(), "费用类型"); @@ -1059,11 +1122,20 @@ public class PreSettlementServiceImpl extends BaseServiceImpl beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + beforeData.put("费用项", row.getFeeItem()); + beforeData.put("调整金额", before); + beforeData.put("结算金额", money(row.getOriginalAmount().add(before))); + afterData.put("费用项", row.getFeeItem()); + afterData.put("调整金额", row.getAdjustAmount()); + afterData.put("结算金额", money(row.getSettlementAmount())); + saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", + "【" + row.getFeeItem() + "-调整金额】从【" + before + "】调整为【" + row.getAdjustAmount() + "】", + "", beforeData, afterData); } } for (PreSettlementSummaryFee manualRow : manualRows) { if (!retainedManualIds.contains(manualRow.getId())) { summaryFeeMapper.deleteById(manualRow.getId()); - saveChange(settlementId, "合计费用项", null, "删除", - "删除" + manualRow.getFeeItem() + "费用" + manualRow.getSettlementAmount(), ""); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + beforeData.put("费用类型", manualRow.getFeeType()); + beforeData.put("费用项", manualRow.getFeeItem()); + beforeData.put("调整金额", money(manualRow.getAdjustAmount())); + beforeData.put("结算金额", money(manualRow.getSettlementAmount())); + beforeData.put("备注", manualRow.getRemark()); + afterData.put("费用类型", ""); + afterData.put("费用项", ""); + afterData.put("调整金额", BigDecimal.ZERO.setScale(2)); + afterData.put("结算金额", BigDecimal.ZERO.setScale(2)); + afterData.put("备注", ""); + saveChange(settlementId, "合计费用项", manualRow.getLineNo(), "删除", + "删除合计费用项【" + manualRow.getFeeItem() + "】金额【" + money(manualRow.getSettlementAmount()) + "】", + "", beforeData, afterData); } } for (PreSettlementSummaryFee generatedRow : existingGeneratedRows) { @@ -1122,32 +1221,63 @@ public class PreSettlementServiceImpl extends BaseServiceImpl fees = detailFees(detail.getId()); + BigDecimal transportQuantity = fees.stream().map(PreSettlementDetailFee::getTransportQuantity) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal mileage = fees.stream().map(PreSettlementDetailFee::getMileage) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal freightAmount = fees.stream().map(PreSettlementDetailFee::getFreightAmount) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal originalAmount = fees.stream().map(PreSettlementDetailFee::getOriginalAmount) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal settlementAmountTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountTax) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal settlementAmountNoTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountNoTax) + .filter(Objects::nonNull).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); Map feeItems = new LinkedHashMap<>(); fees.forEach(fee -> parseFeeItems(fee.getFeeItemsJson()).forEach((name, amount) -> feeItems.merge(name, money(amount), BigDecimal::add))); + // 运输单价无法按行简单求和,与前端暂存逻辑一致:取首行费用单价。 + BigDecimal unitPrice = fees.stream().map(PreSettlementDetailFee::getUnitPrice) + .filter(Objects::nonNull).findFirst().orElse(null); + String remark = fees.stream().map(PreSettlementDetailFee::getRemark) + .filter(Func::isNotEmpty).findFirst().orElse(detail.getRemark()); + detail.setTransportQuantity(money(transportQuantity)); + detail.setMileage(mileage); + detail.setUnitPrice(unitPrice); detail.setFreightAmount(money(freightAmount)); detail.setOriginalAmount(money(originalAmount)); detail.setAdjustAmount(money(settlementAmountTax.subtract(originalAmount))); detail.setSettlementAmountTax(money(settlementAmountTax)); + detail.setSettlementAmountNoTax(fees.stream().anyMatch(fee -> fee.getSettlementAmountNoTax() != null) + ? money(settlementAmountNoTax) : null); detail.setFeeItemsJson(JsonUtil.toJson(feeItems)); + detail.setRemark(limitRemark(remark, 200)); detailMapper.updateById(detail); } + /** + * 结算金额以结算合计为准(含手工新增费用),与详情页合计及付款申请取数口径一致。 + * 合计行为空时回退为明细含税金额合计。 + */ private void refreshSettlementAmount(PreSettlement settlement) { - BigDecimal settlementAmount = listDetails(settlement.getId()).stream() - .map(PreSettlementDetail::getSettlementAmountTax).map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); + List summaryFees = listSummaryFees(settlement.getId()); + BigDecimal settlementAmount; + if (!summaryFees.isEmpty()) { + settlementAmount = summaryFees.stream() + .map(PreSettlementSummaryFee::getSettlementAmount) + .map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } else { + settlementAmount = listDetails(settlement.getId()).stream() + .map(PreSettlementDetail::getSettlementAmountTax) + .map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } settlement.setSettlementAmount(money(settlementAmount)); BigDecimal rate = normalizeRate(settlement.getExchangeRate(), settlement.getCurrency()); settlement.setExchangeRate(rate); @@ -1366,6 +1496,34 @@ public class PreSettlementServiceImpl extends BaseServiceImpl details) { + if (details == null || details.isEmpty()) { + return; + } + List sourceDetailIds = details.stream() + .map(PreSettlementDetail::getSourceDetailId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (sourceDetailIds.isEmpty()) { + return; + } + Map billingTypeMap = sourceFeeMapper.selectList( + Wrappers.lambdaQuery() + .in(ReceivablePayableCargoFee::getDetailId, sourceDetailIds) + .orderByAsc(ReceivablePayableCargoFee::getLineNo)) + .stream() + .filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) + .collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId, + Collectors.mapping(ReceivablePayableCargoFee::getBillingType, + Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new), + values -> String.join("、", values))))); + for (PreSettlementDetail detail : details) { + detail.setBillingType(billingTypeMap.getOrDefault(detail.getSourceDetailId(), "")); + } + } + private List listDetails(Long settlementId) { return detailMapper.selectList(Wrappers.lambdaQuery() .eq(PreSettlementDetail::getPreSettlementId, settlementId) @@ -1405,6 +1563,40 @@ public class PreSettlementServiceImpl extends BaseServiceImpl changes = new ArrayList<>(); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + if (creating) { + changes.add("新增合计费用项【" + row.getFeeItem() + "】金额【" + money(row.getSettlementAmount()) + "】"); + beforeData.put("费用类型", ""); + beforeData.put("费用项", ""); + beforeData.put("调整金额", BigDecimal.ZERO.setScale(2)); + beforeData.put("结算金额", BigDecimal.ZERO.setScale(2)); + beforeData.put("备注", ""); + afterData.put("费用类型", row.getFeeType()); + afterData.put("费用项", row.getFeeItem()); + afterData.put("调整金额", money(row.getAdjustAmount())); + afterData.put("结算金额", money(row.getSettlementAmount())); + afterData.put("备注", row.getRemark()); + saveChange(settlementId, "合计费用项", row.getLineNo(), "新增", String.join(";", changes), "", + beforeData, afterData); + return; + } + appendTextChange(changes, beforeData, afterData, "费用类型", beforeFeeType, row.getFeeType()); + appendTextChange(changes, beforeData, afterData, "费用项", beforeFeeItem, row.getFeeItem()); + appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount()); + appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount()); + appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark()); + if (changes.isEmpty()) { + return; + } + saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "", + beforeData, afterData); + } + private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType, String content, String reason) { saveChange(settlementId, changeType, lineNo, operationType, content, reason, null, null); @@ -1619,44 +1811,25 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(PreSettlementAdvance::getAdvanceNo, prefix) - .orderByDesc(PreSettlementAdvance::getAdvanceNo) - .last("limit 1")); - return prefix + String.format("%05d", nextSequence(latest == null ? null : latest.getAdvanceNo(), prefix)); - } - - private String nextDailyCode(String code, Function getter) { - String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - PreSettlement latest = list(Wrappers.lambdaQuery() - .and(query -> query.likeRight(PreSettlement::getPreSettlementNo, prefix) - .or().likeRight(PreSettlement::getFormalSettlementNo, prefix)) - .orderByDesc(PreSettlement::getCreateTime) - .last("limit 1")).stream().max(Comparator.comparing(item -> { - String value = getter.apply(item); - return value == null ? "" : value; - })).orElse(null); - String latestCode = latest == null ? null : getter.apply(latest); - return prefix + String.format("%05d", nextSequence(latestCode, prefix)); - } - - private int nextSequence(String latestCode, String prefix) { - if (latestCode == null || !latestCode.startsWith(prefix)) return 1; - try { - return Integer.parseInt(latestCode.substring(prefix.length())) + 1; - } catch (NumberFormatException exception) { - return 1; - } + Long maxSequence = advanceMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java index 64526a01..40ddbb6b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java @@ -82,6 +82,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl beforeData = buildProjectChangeSnapshot(oldRecord); prepare(projectApply); + normalizeUndertakeDept(projectApply); copyChangeFields(oldRecord, projectApply); validateSubmit(oldRecord); validateChange(oldRecord, projectApply.getChangeType()); @@ -379,13 +382,13 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl wrapper .nested(formal -> formal.eq(ProjectApply::getEffectiveType, EFFECTIVE_FORMAL) - .in(ProjectApply::getApprovalStatus, STATUS_APPROVED, "change_approved")) + .in(ProjectApply::getApprovalStatus, STATUS_APPROVED, STATUS_CHANGE_APPROVED)) .or(temporary -> temporary.eq(ProjectApply::getEffectiveType, EFFECTIVE_TEMPORARY) .ne(ProjectApply::getApprovalStatus, STATUS_VOIDED))); } @@ -559,6 +562,11 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl= 0 ? "claimed" : "partial"; + return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? "claimed" : "partial"; + } + + /** + * 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致) + */ + private BigDecimal claimCeiling(KingdeeReceiptFlow flow) { + BigDecimal unclaimed = money(flow.getUnclaimedAmount()); + BigDecimal receipt = money(flow.getReceiptAmount()); + if (receipt.compareTo(BigDecimal.ZERO) <= 0) { + return unclaimed; + } + if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) { + return unclaimed; + } + return unclaimed.min(receipt); } private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java index fe427db1..e6c6aecd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java @@ -194,12 +194,16 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl> settlementCandidates(String keyword, Long flowId) { existing(flowId); + // 本流水历史关联结算单优先保留(避免仅按创建时间 limit 截断后选不到) + Set relatedIds = relatedSettlementIds(flowId); List settlements = formalSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(FormalSettlement::getSettlementType, RECEIVABLE) @@ -210,7 +214,24 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl loadedIds = settlements.stream().map(FormalSettlement::getId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + List missingIds = relatedIds.stream().filter(id -> !loadedIds.contains(id)).toList(); + if (!missingIds.isEmpty()) { + List relatedSettlements = formalSettlementMapper.selectList( + Wrappers.lambdaQuery() + .in(FormalSettlement::getId, missingIds) + .eq(FormalSettlement::getSettlementType, RECEIVABLE) + .eq(FormalSettlement::getApprovalStatus, APPROVED) + .eq(FormalSettlement::getStatus, 1)); + List merged = new ArrayList<>(relatedSettlements.size() + settlements.size()); + merged.addAll(relatedSettlements); + merged.addAll(settlements); + settlements = merged; + } + } // 候选结算单的累计认领金额一次聚合查询(避免逐单 N+1) Map claimedMap = receiptClaimedAmounts(settlements.stream() .map(FormalSettlement::getId).toList()); @@ -218,6 +239,14 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl candidateRow(settlement, claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO))) .filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0) + .sorted((left, right) -> { + boolean leftRelated = relatedIds.contains(toLong(left.get("id"))); + boolean rightRelated = relatedIds.contains(toLong(right.get("id"))); + if (leftRelated == rightRelated) { + return 0; + } + return leftRelated ? -1 : 1; + }) .toList(); } @@ -234,6 +263,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptClaimSettlement::getFormalSettlementId, formalSettlementId) .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0) .orderByDesc(ReceiptClaimSettlement::getCreateTime)); if (relations.isEmpty()) { return List.of(); @@ -315,19 +345,19 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl 0) { - throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 " - + unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO)); + if (previousFlowClaimed.add(allocatedTotal).compareTo(claimCeiling) > 0) { + throw new ServiceException("累计认领金额不能超过可认领上限,剩余可认领 " + + claimCeiling.subtract(previousFlowClaimed).max(BigDecimal.ZERO)); } // 当前用户部门 @@ -380,6 +410,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl relatedSettlementIds(Long flowId) { + if (flowId == null) { + return Set.of(); + } + return claimSettlementMapper.selectList(Wrappers.lambdaQuery() + .select(ReceiptClaimSettlement::getFormalSettlementId) + .eq(ReceiptClaimSettlement::getReceiptFlowId, flowId) + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream() + .map(ReceiptClaimSettlement::getFormalSettlementId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(HashSet::new)); + } + + /** + * 继续认领预填:本流水历史关联且仍有剩余可认领金额的结算单 + */ + private List> relatedSettlementCandidates(Long flowId) { + Set relatedIds = relatedSettlementIds(flowId); + if (relatedIds.isEmpty()) { + return List.of(); + } + List settlements = formalSettlementMapper.selectList( + Wrappers.lambdaQuery() + .in(FormalSettlement::getId, relatedIds) + .eq(FormalSettlement::getSettlementType, RECEIVABLE) + .eq(FormalSettlement::getApprovalStatus, APPROVED) + .eq(FormalSettlement::getStatus, 1)); + Map claimedMap = receiptClaimedAmounts(settlements.stream() + .map(FormalSettlement::getId).toList()); + return settlements.stream() + .map(settlement -> candidateRow(settlement, + claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO))) + .filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0) + .toList(); + } + /** * 批量聚合结算单的累计有效认领金额:一次 IN 查询按结算单分组求和,避免逐单 N+1 */ @@ -631,7 +704,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaQuery() .in(ReceiptClaimSettlement::getFormalSettlementId, ids) - .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream() .collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId, Collectors.reducing(BigDecimal.ZERO, item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add))); @@ -640,12 +714,42 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptClaimSettlement::getReceiptFlowId, flowId) - .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream() .map(ReceiptClaimSettlement::getAllocatedReceiptAmount) .map(this::money) .reduce(BigDecimal.ZERO, BigDecimal::add); } + /** + * 可认领上限:公告未认领金额与收款金额取较小正值。 + * 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。 + */ + private BigDecimal claimCeiling(KingdeeReceiptFlow flow) { + BigDecimal unclaimed = money(flow.getUnclaimedAmount()); + BigDecimal receipt = money(flow.getReceiptAmount()); + if (receipt.compareTo(BigDecimal.ZERO) <= 0) { + return unclaimed; + } + if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) { + return unclaimed; + } + return unclaimed.min(receipt); + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Long longValue) { + return longValue; + } + if (value instanceof Number number) { + return number.longValue(); + } + return Func.toLong(String.valueOf(value)); + } + private KingdeeReceiptFlow lockedFlow(Long flowId) { KingdeeReceiptFlow flow = baseMapper.selectOne(Wrappers.lambdaQuery() .eq(KingdeeReceiptFlow::getId, flowId) @@ -824,18 +928,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl allocations = claimSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId()) .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0) .orderByAsc(ReceiptClaimSettlement::getFormalSettlementId)); if (allocations.isEmpty()) { return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送"); @@ -1075,13 +1181,13 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl= 0 ? CLAIMED : PARTIAL; + return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? CLAIMED : PARTIAL; } private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index e5cbabb4..f27388f7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -607,14 +607,22 @@ public class ReceivablePayableDetailServiceImpl BigDecimal transportQuantity = money(adjusted.getTransportQuantity()); BigDecimal mileage = money(adjusted.getMileage()); BigDecimal freightAmount = money(adjusted.getFreightAmount()); + BigDecimal unitPrice = adjusted.getUnitPrice() == null ? null : money(adjusted.getUnitPrice()); Map effectiveFeeItems = feeItems; - boolean billingBasisChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0 - || money(existing.getMileage()).compareTo(mileage) != 0; - if (billingBasisChanged) { + boolean quantityChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0; + boolean mileageChanged = money(existing.getMileage()).compareTo(mileage) != 0; + boolean unitPriceChanged = money(existing.getUnitPrice()).compareTo(money(unitPrice)) != 0; + if (quantityChanged || mileageChanged || unitPriceChanged) { + String changedField = quantityChanged && !mileageChanged ? "transportQuantity" + : mileageChanged && !quantityChanged ? "mileage" + : unitPriceChanged && !quantityChanged && !mileageChanged ? "unitPrice" : null; AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, existing, - transportQuantity, mileage, freightAmount, feeItems); + transportQuantity, mileage, freightAmount, feeItems, unitPrice, changedField); freightAmount = calculation.freightAmount(); effectiveFeeItems = calculation.feeItems(); + if (calculation.unitPrice() != null) { + adjusted.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP)); + } } appendChange(changes, "规格", existing.getSpecification(), adjusted.getSpecification()); appendChange(changes, "型号", existing.getModel(), adjusted.getModel()); @@ -678,15 +686,21 @@ public class ReceivablePayableDetailServiceImpl } validateNonNegative(request.getTransportQuantity(), "运输量"); validateNonNegative(request.getMileage(), "里程"); + validateNonNegative(request.getUnitPrice(), "运输单价"); AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, fee, money(request.getTransportQuantity()), money(request.getMileage()), - money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems())); + money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems()), + request.getUnitPrice() == null ? null : money(request.getUnitPrice()), + request.getChangedField()); ReceivablePayableCargoFeeVO result = Objects.requireNonNull( BeanUtil.copyProperties(fee, ReceivablePayableCargoFeeVO.class)); result.setTransportQuantity(request.getTransportQuantity()); result.setMileage(request.getMileage()); result.setFreightAmount(calculation.freightAmount()); result.setFeeItems(new LinkedHashMap<>(calculation.feeItems())); + if (calculation.unitPrice() != null) { + result.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP)); + } BigDecimal afterAmount = adjustedAfterAmount(calculation.freightAmount(), calculation.feeItems()); result.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount()))); result.setAfterAmount(afterAmount); @@ -836,6 +850,12 @@ public class ReceivablePayableDetailServiceImpl if (existsByWaybill(waybill.getId(), targetSettlementType)) { continue; } + if ("payable".equals(targetSettlementType) + && trimToNull(waybill.getCarrierName()) != null + && !matchesWaybillCarrier(contract, waybill)) { + throw new ServiceException("运单【" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId())) + + "】的承运商不是所选承运商合同的乙方,不能使用项目内其他承运商合同生成应付"); + } ReceivablePayableDetail detail = buildDetail(waybill, contract, request.getBillingPlanId(), targetSettlementType); detail.setSourceType(SOURCE_MANUAL_GENERATION); save(detail); @@ -1064,11 +1084,20 @@ public class ReceivablePayableDetailServiceImpl List payableWaybills = waybills.stream() .filter(waybill -> !isSelfTransport(waybill)).toList(); if (payableWaybills.isEmpty()) return; + List carrierMatchedWaybills = payableWaybills.stream() + .filter(waybill -> matchesWaybillCarrier(carrierContract, waybill)).toList(); + List otherCarrierWaybills = payableWaybills.stream() + .filter(waybill -> !matchesWaybillCarrier(carrierContract, waybill)).toList(); + if (!otherCarrierWaybills.isEmpty()) { + generateAutomaticWaybillDetails(otherCarrierWaybills, false, + this::resolveCarrierContract, currentUserId, generateTime); + } + if (carrierMatchedWaybills.isEmpty()) return; generateAutomaticDetail(carrierContract, "payable", - () -> existsByLoading(loadingNo, "payable"), payableWaybills, + () -> existsByLoading(loadingNo, "payable"), carrierMatchedWaybills, (fees, unitPrice) -> { normalizeWaybillFeeLines(fees); - return buildLoadingDetail(loadingNo, carrierContract, payableWaybills, fees, unitPrice); + return buildLoadingDetail(loadingNo, carrierContract, carrierMatchedWaybills, fees, unitPrice); }, false, currentUserId, generateTime); } @@ -1118,22 +1147,54 @@ public class ReceivablePayableDetailServiceImpl private ContractManage resolveWaybillCarrierContract(Waybill waybill) { if (Func.isNotEmpty(waybill.getLoadingNo())) return null; + return resolveCarrierContract(waybill); + } + + /** + * 应付只认运单选择的承运商。 + * 运单已记录承运商合同时,乙方必须就是该承运商;否则按承运商名称回查同一项目下的承运商合同。 + * 不允许落到项目里其他承运商合同的客商。 + */ + private ContractManage resolveCarrierContract(Waybill waybill) { + if (waybill == null || isSelfTransport(waybill)) { + return null; + } + String carrierName = trimToNull(waybill.getCarrierName()); if (Func.isNotEmpty(waybill.getCarrierContractId())) { - ContractManage contract = contractManageService.getById(waybill.getCarrierContractId()); - if (contract == null || !"承运商合同".equals(contract.getContractCategory())) { + ContractManage recorded = contractManageService.getById(waybill.getCarrierContractId()); + if (recorded == null || !"承运商合同".equals(recorded.getContractCategory())) { throw new ServiceException("运单记录的承运商合同不存在或合同类别不正确"); } - return contract; + if (carrierName == null || matchesWaybillCarrier(recorded, waybill)) { + return recorded; + } } - return findCarrierContract(waybill); + ContractManage matched = findCarrierContract(waybill); + if (matched != null && matchesWaybillCarrier(matched, waybill)) { + return matched; + } + return null; + } + + private boolean matchesWaybillCarrier(ContractManage contract, Waybill waybill) { + if (contract == null || waybill == null || !"承运商合同".equals(contract.getContractCategory())) { + return false; + } + if (waybill.getProjectId() != null && contract.getProjectId() != null + && !Objects.equals(waybill.getProjectId(), contract.getProjectId())) { + return false; + } + String carrierName = trimToNull(waybill.getCarrierName()); + String partyB = trimToNull(contract.getPartyB()); + return carrierName != null && carrierName.equals(partyB); } private ContractManage findCarrierContract(Waybill waybill) { - if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) - || Func.isEmpty(waybill.getCarrierName())) { + String carrierName = trimToNull(waybill.getCarrierName()); + if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) || carrierName == null) { return null; } - // 承运商合同甲方应与运单绑定的客户合同甲方一致,不能直接使用运单的客户名称拼接字段。 + // 承运商合同甲方应与运单绑定的客户合同甲方一致,乙方必须是运单选择的承运商。 ContractManage customerContract = contractManageService.getById(waybill.getContractId()); if (customerContract == null || Func.isEmpty(customerContract.getPartyA())) { return null; @@ -1142,13 +1203,38 @@ public class ReceivablePayableDetailServiceImpl .eq(ContractManage::getIsDeleted, 0) .eq(ContractManage::getProjectId, waybill.getProjectId()) .eq(ContractManage::getPartyA, customerContract.getPartyA()) - .eq(ContractManage::getPartyB, waybill.getCarrierName()) + .eq(ContractManage::getPartyB, carrierName) .eq(ContractManage::getContractCategory, "承运商合同") .and(wrapper -> wrapper.isNull(ContractManage::getContractStage) .or().ne(ContractManage::getContractStage, "terminated")) .orderByDesc(ContractManage::getCreateTime), false); } + private String trimToNull(String value) { + if (value == null) { + return null; + } + String text = value.trim(); + return text.isEmpty() ? null : text; + } + + /** + * 应付客商取运单选择的承运商,避免写成项目内其他承运商合同的乙方。 + */ + private String resolveCounterpartyName(Waybill waybill, ContractManage contract, String settlementType) { + if ("payable".equals(settlementType)) { + String carrierName = trimToNull(waybill == null ? null : waybill.getCarrierName()); + if (carrierName != null) { + return carrierName; + } + return contract == null ? null : contract.getPartyB(); + } + if (contract != null && Func.isNotEmpty(contract.getPartyA())) { + return contract.getPartyA(); + } + return waybill == null ? null : waybill.getCustomerName(); + } + @Override @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW, rollbackFor = Exception.class) @@ -1201,12 +1287,24 @@ public class ReceivablePayableDetailServiceImpl .collect(Collectors.toMap(ContractManage::getId, contract -> contract)); Map> carrierWaybills = waybills.stream().collect(Collectors.groupingBy( Waybill::getCarrierContractId, LinkedHashMap::new, Collectors.toList())); + List mismatchedCarrierWaybills = waybills.stream() + .filter(waybill -> !matchesWaybillCarrier(carrierContracts.get(waybill.getCarrierContractId()), waybill)) + .toList(); + if (!mismatchedCarrierWaybills.isEmpty()) { + generateAutomaticWaybillDetails(mismatchedCarrierWaybills, false, + this::resolveCarrierContract, currentUserId, generateTime); + } for (Map.Entry> entry : carrierWaybills.entrySet()) { ContractManage carrierContract = carrierContracts.get(entry.getKey()); if (carrierContract == null || !"承运商合同".equals(carrierContract.getContractCategory())) { throw new ServiceException("总单【" + masterOrder.getMasterNo() + "】记录的承运商合同不存在或合同类别不正确"); } - List currentCarrierWaybills = entry.getValue(); + List currentCarrierWaybills = entry.getValue().stream() + .filter(waybill -> matchesWaybillCarrier(carrierContract, waybill)) + .toList(); + if (currentCarrierWaybills.isEmpty()) { + continue; + } generateAutomaticDetail(carrierContract, "payable", () -> existsByMasterOrderContract(masterOrder.getMasterNo(), "payable", carrierContract.getId()), currentCarrierWaybills, @@ -1635,9 +1733,7 @@ public class ReceivablePayableDetailServiceImpl detail.setDeptId(contract == null ? waybill.getDeptId() : contract.getOrganizationId()); detail.setDeptName(contract == null ? waybill.getDeptName() : contract.getOrganizationName()); detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate()); - detail.setCustomerName(contract == null - ? ("payable".equals(settlementType) ? waybill.getCarrierName() : waybill.getCustomerName()) - : ("payable".equals(settlementType) ? contract.getPartyB() : contract.getPartyA())); + detail.setCustomerName(resolveCounterpartyName(waybill, contract, settlementType)); detail.setContractId(contract == null ? waybill.getContractId() : contract.getId()); detail.setContractNo(contract == null ? null : contract.getContractNo()); detail.setContractName(contract == null ? waybill.getContractName() : contract.getContractName()); @@ -1652,8 +1748,8 @@ public class ReceivablePayableDetailServiceImpl detail.setArrivalContact(waybill.getArrivalContact()); detail.setArrivalPhone(waybill.getArrivalPhone()); detail.setTransportType(waybill.getTransportType()); - detail.setCargoName(waybill.getCargoName()); - detail.setCargoType(waybill.getCargoType()); + detail.setCargoName(joinDistinctGoods(waybill, "cargoName", waybill.getCargoName())); + detail.setCargoType(joinDistinctGoods(waybill, "cargoType", waybill.getCargoType())); detail.setTransportQuantity(resolveWaybillTransportQuantity(waybill)); detail.setQuantityUnit(waybill.getQuantityUnit()); detail.setMileage(normalizeGeneratedMileage(waybill.getMileage())); @@ -1668,6 +1764,21 @@ public class ReceivablePayableDetailServiceImpl return detail; } + /** 一单多货时主表只保留首行货物,应付明细改记全部货物名称、货物类型。 */ + private String joinDistinctGoods(Waybill waybill, String field, String fallback) { + LinkedHashSet values = new LinkedHashSet<>(); + for (Map goods : parseList(waybill.getGoodsJson())) { + String value = stringValue(goods, field, ""); + if (Func.isNotEmpty(value)) { + values.add(value.trim()); + } + } + if (values.isEmpty()) { + return fallback; + } + return String.join(",", values); + } + /** * 多货物运单优先按 goodsJson 数量合计作为运输总量,避免主表 quantity 只落了首行。 */ @@ -2434,10 +2545,16 @@ public class ReceivablePayableDetailServiceImpl private AdjustedFeeCalculation calculateAdjustedFee(ReceivablePayableDetail detail, ReceivablePayableCargoFee fee, BigDecimal transportQuantity, BigDecimal mileage, BigDecimal freightAmount, - Map feeItems) { + Map feeItems, BigDecimal unitPrice, + String changedField) { if (isManualFee(fee)) { throw new ServiceException("手工费用不支持按合同计费规则试算"); } + boolean transportChanged = Func.isEmpty(changedField) + || List.of("transportQuantity", "mileage", "unitPrice").contains(changedField); + if (!transportChanged) { + return new AdjustedFeeCalculation(freightAmount, new LinkedHashMap<>(feeItems), null); + } Waybill adjustedWaybill = adjustedWaybill(detail, fee, transportQuantity, mileage); List> rules = parseList(fee.getBillingRulesJson()); if (rules.isEmpty()) { @@ -2453,26 +2570,94 @@ public class ReceivablePayableDetailServiceImpl if (rules.isEmpty()) { throw new ServiceException("未找到费用明细对应的合同计费规则,请先更新费用"); } - Map calculatedAmounts = new LinkedHashMap<>(); - BigDecimal calculatedFreight = BigDecimal.ZERO; - boolean freightRuleMatched = false; + Map calculatedFeeItems = new LinkedHashMap<>(feeItems); + BigDecimal calculatedFreight = null; + BigDecimal resolvedUnitPrice = null; + boolean calculated = false; for (Map rule : rules) { - BigDecimal amount = calculateRule(rule, adjustedWaybill); - if (amount == null) continue; + if (!shouldRecalculateRule(rule, changedField)) { + continue; + } + String type = stringValue(rule, "billingType", ""); + boolean freightRule = isFreightRule(rule); + Map effectiveRule = rule; + if (freightRule && unitPriceAdjustable(type) && unitPrice != null) { + effectiveRule = new LinkedHashMap<>(rule); + effectiveRule.put("unitPrice", unitPrice); + resolvedUnitPrice = unitPrice; + } + BigDecimal amount = calculateRule(effectiveRule, adjustedWaybill); + if (amount == null) { + continue; + } + calculated = true; String feeItem = stringValue(rule, "feeItem", "费用"); - calculatedAmounts.merge(feeItem, amount, BigDecimal::add); - if (isFreightRule(rule)) { - calculatedFreight = calculatedFreight.add(amount); - freightRuleMatched = true; + calculatedFeeItems.put(feeItem, amount); + if (freightRule) { + calculatedFreight = (calculatedFreight == null ? BigDecimal.ZERO : calculatedFreight).add(amount); + if (intervalBilling(type)) { + resolvedUnitPrice = matchedIntervalUnitPrice(rule, adjustedWaybill); + } } } - if (calculatedAmounts.isEmpty()) { + if (!calculated && Func.isEmpty(changedField)) { throw new ServiceException("费用明细对应的合同计费规则无法试算,请先更新费用"); } - Map calculatedFeeItems = new LinkedHashMap<>(feeItems); - calculatedAmounts.forEach(calculatedFeeItems::put); - return new AdjustedFeeCalculation(freightRuleMatched ? calculatedFreight : freightAmount, - calculatedFeeItems); + return new AdjustedFeeCalculation(calculatedFreight == null ? freightAmount : calculatedFreight, + calculatedFeeItems, resolvedUnitPrice); + } + + /** + * 运费在运输总量、里程、可调整单价变化后按方案重算。 + * 其他费用仅在计费要素使用了本次变化的运输总量或里程时重算,便于保留手工修改的费用总额。 + */ + private boolean shouldRecalculateRule(Map rule, String changedField) { + String element = stringValue(rule, "billingElement", ""); + String type = stringValue(rule, "billingType", ""); + if (isFreightRule(rule)) { + return !"unitPrice".equals(changedField) || unitPriceAdjustable(type); + } + if ("unitPrice".equals(changedField) || Func.isEmpty(changedField)) { + return Func.isEmpty(changedField) && (usesTransportQuantity(element) || usesMileage(element)); + } + if ("transportQuantity".equals(changedField)) { + return usesTransportQuantity(element); + } + if ("mileage".equals(changedField)) { + return usesMileage(element); + } + return false; + } + + private boolean usesTransportQuantity(String element) { + return List.of("按重量", "按体积", "按数量", "按吨·公里").contains(element); + } + + private boolean usesMileage(String element) { + return List.of("按里程", "按吨·公里").contains(element); + } + + private boolean unitPriceAdjustable(String billingType) { + return !tierUnitPrice(billingType) && !intervalBilling(billingType); + } + + private boolean tierUnitPrice(String billingType) { + return "阶梯单价".equals(billingType); + } + + private boolean intervalBilling(String billingType) { + String type = billingType == null ? "" : billingType; + return "区间单价".equals(type) || (type.contains("区间") && type.contains("一口价")); + } + + private BigDecimal matchedIntervalUnitPrice(Map rule, Waybill waybill) { + BigDecimal fallback = decimal(rule.get("unitPrice")); + return range(ranges(rule), measure(rule, waybill)) + .map(matched -> { + BigDecimal matchedPrice = decimal(matched.get("unitPrice")); + return matchedPrice.signum() == 0 ? fallback : matchedPrice; + }) + .orElse(fallback); } private Waybill adjustedWaybill(ReceivablePayableDetail detail, ReceivablePayableCargoFee fee, @@ -2545,7 +2730,8 @@ public class ReceivablePayableDetailServiceImpl return name != null && (name.contains("运费") || name.contains("运输费")); } - private record AdjustedFeeCalculation(BigDecimal freightAmount, Map feeItems) { + private record AdjustedFeeCalculation(BigDecimal freightAmount, Map feeItems, + BigDecimal unitPrice) { } private void appendChange(List changes, String field, BigDecimal before, BigDecimal after) { @@ -2884,21 +3070,29 @@ public class ReceivablePayableDetailServiceImpl if (detailIds.isEmpty()) return; List fees = cargoFeeMapper.selectList( Wrappers.lambdaQuery() - .select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName) + .select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName, + ReceivablePayableCargoFee::getBillingType) .in(ReceivablePayableCargoFee::getDetailId, detailIds) .eq(ReceivablePayableCargoFee::getIsDeleted, 0) .orderByAsc(ReceivablePayableCargoFee::getDetailId) .orderByAsc(ReceivablePayableCargoFee::getLineNo)); Map> cargoNames = new LinkedHashMap<>(); + Map> billingTypes = new LinkedHashMap<>(); for (ReceivablePayableCargoFee fee : fees) { - if (Func.isEmpty(fee.getCargoName())) continue; - cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName()); + if (Func.isNotEmpty(fee.getCargoName())) { + cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName()); + } + if (Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) { + billingTypes.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getBillingType()); + } } details.forEach(detail -> { Set names = cargoNames.get(detail.getId()); if (Func.isNotEmpty(names)) { detail.setCargoName(String.join(",", names)); } + Set types = billingTypes.get(detail.getId()); + detail.setBillingType(Func.isEmpty(types) ? "" : String.join("、", types)); }); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java index 8dda1c1c..0f5b680b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java @@ -4,12 +4,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.SpringUtil; import org.springblade.system.cache.UserCache; +import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.event.KingdeeBillApprovedEvent; +import org.springblade.transport.support.KingdeeApBillSyncSupport; +import org.springblade.transport.support.KingdeeArBillSyncSupport; import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper; import org.springblade.transport.mapper.FormalSettlementDetailMapper; import org.springblade.transport.mapper.FormalSettlementChangeRecordMapper; @@ -33,7 +39,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 implements ISettlementAdjustmentService { private static final String PAID = "paid"; @@ -63,6 +73,11 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl selectPage(IPage page, SettlementAdjustmentVO query) { @@ -204,24 +219,142 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl + * 非事务编排:克隆+应用(独立事务)→ 推送(外部远程)→ 收尾回填,推送失败不回滚克隆, + * 重试时按来源原单号幂等复用已克隆新单,从推送步骤续上。 + */ @Override - @Transactional(rollbackFor = Exception.class) public String repush(Long adjustmentId) { SettlementAdjustment adjustment = existing(adjustmentId); - if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批通过的结算调整单允许重新推送"); - FormalSettlement formal = formalMapper.selectById(adjustment.getFormalSettlementId()); - if (formal == null || !"synced".equals(formal.getKingdeeSyncStatus())) throw new ServiceException("关联正式结算单尚未推送金蝶,无需重新推送"); - String kingdeeNo = "K3AP" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now()); - formal.setKingdeeBillNo(kingdeeNo); formal.setSyncedTime(LocalDateTime.now()); formalMapper.updateById(formal); - return kingdeeNo; + if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) { + throw new ServiceException("仅审批通过的结算调整单允许重新推送"); + } + FormalSettlement original = formalMapper.selectById(adjustment.getFormalSettlementId()); + if (original == null) { + throw new ServiceException("关联正式结算单不存在"); + } + if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(original.getKingdeeSyncStatus())) { + throw new ServiceException("结算单[" + 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 kingdeeWriteOffPrecheck(Long formalSettlementId) { + Map result = new HashMap<>(); + FormalSettlement settlement = formalMapper.selectById(formalSettlementId); + if (settlement == null) { + throw new ServiceException("关联正式结算单不存在"); + } + if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(settlement.getKingdeeSyncStatus())) { + result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED); + result.put("writtenOff", null); + result.put("message", "该结算单尚未推送金蝶,审批通过后将自动推送原单"); + return result; + } + result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); + try { + boolean writtenOff = this.kingdeeWrittenOff(settlement); + result.put("writtenOff", writtenOff); + result.put("message", writtenOff + ? "该结算单已推送金蝶且旧单已冲销,审批后可直接重新推送新单" + : "该结算单已推送金蝶且旧单尚未冲销,审批通过后需先由财务在金蝶完成冲销,才能重新推送"); + } catch (Exception exception) { + // 预检为软提示:金蝶查询异常降级为「未校验」,不阻断建单,由审批分流与重推闸门兜底 + log.warn("结算调整单建单预检:金蝶冲销状态查询失败,降级未校验,正式结算单[{}]", + settlement.getFormalSettlementNo(), exception); + result.put("writtenOff", null); + result.put("message", "金蝶冲销状态查询失败,未校验;审批后请以实际冲销情况为准"); + } + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String approve(SettlementAdjustmentStatusRequest request) { SettlementAdjustment adjustment = existing(request.getId()); if (!TransportBusiConstant.REVIEWING.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批中的结算调整单允许审核"); FormalSettlement formal = adjustableFormalSettlement(adjustment.getFormalSettlementId()); + boolean synced = KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(formal.getKingdeeSyncStatus()); + if (synced) { + // 已推送场景:原单业务数据零改动,仅追加 1 条重推提示变更记录,待财务在金蝶手工冲销旧单后重推 + saveFormalChange(formal.getId(), "合计费用项", null, "调整", + "结算调整单" + adjustment.getAdjustmentNo() + "审批通过,调整金额【" + money(adjustment.getAdjustmentAmount()) + "】,待重推金蝶", + adjustment.getRemark()); + } else { + // 未推送场景:回写原单费用/金额并重算结算金额,事务提交后由事件监听自动推送金蝶 + applyAdjustmentToSettlement(formal, adjustment, Map.of(), Map.of()); + saveFormalChange(formal.getId(), "合计费用项", null, "调整", + "调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】", + adjustment.getRemark()); + } + adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment); + if (!synced) { + // 审批事务提交后(AFTER_COMMIT)自动推原单,成功回填调整单 synced + 金蝶单号、失败置 failed + eventPublisher.publishEvent(new KingdeeBillApprovedEvent( + KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT, formal.getId())); + return null; + } + return "原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理后再重新推送"; + } + + /** + * 应用调整到目标正式结算单(未推送场景作用于原单,重推场景作用于克隆新单): + * 回写明细费项金额、汇总费用项、重算明细与结算金额;明细/费项 id 经映射转换(原单调整作用于原单时传空映射)。 + * + * @param formal 目标正式结算单(改动后需落库) + * @param adjustment 结算调整单 + * @param detailIdMap 原结算明细id -> 目标结算明细id(原单场景传空 Map) + * @param feeIdMap 原明细费项id -> 目标明细费项id(原单场景传空 Map) + */ + private void applyAdjustmentToSettlement(FormalSettlement formal, SettlementAdjustment adjustment, + Map detailIdMap, Map feeIdMap) { for (SettlementAdjustmentDetail item : detailMapper.selectList(Wrappers.lambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) { if (money(item.getAdjustmentAmountTax()).signum() == 0) continue; if (isManualFee(item)) { @@ -229,12 +362,14 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl 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 clones = formalMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlement::getOriginFormalSettlementNo, originFormalSettlementNo) + .orderByDesc(FormalSettlement::getCreateTime)); + return clones.isEmpty() ? null : clones.get(0); + } + + /** 按原单旧防重键(原结算单号_明细单据号)查金蝶是否已冲销(应付 A102-4 / 应收 A302-4,部分冲销放行) */ + private boolean kingdeeWrittenOff(FormalSettlement settlement) { + List details = formalDetailMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId()) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + if (Func.isEmpty(details)) { + return false; + } + if ("receivable".equals(settlement.getSettlementType())) { + return kingdeeArBillSyncSupport.queryArBillWrittenOff(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details)); + } + return kingdeeApBillSyncSupport.queryApBillWrittenOff(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details)); } private void saveFormalChange(Long settlementId, String changeType, Integer lineNo, @@ -392,7 +558,12 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpllambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); } + /** 生成当日结算调整单号;按最大流水号 +1,且计入逻辑删除数据 */ + private synchronized String nextNo() { + String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1); + } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String requiredText(String value, String field, int max) { if (Func.isEmpty(value)) throw new ServiceException(field + "不能为空"); return limit(value.trim(), max); } private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("内容不能超过" + max + "个字"); return value; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java index 34ae5a9b..6270dbc4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java @@ -32,7 +32,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springblade.common.excel.ImportFailureExcelUtil; -import org.springblade.core.excel.util.ExcelUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; @@ -41,12 +40,16 @@ import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.DictBizCache; +import org.springblade.system.cache.DictCache; import org.springblade.system.cache.UserCache; import org.springblade.system.feign.ISysClient; +import org.springblade.system.pojo.entity.DictBiz; import org.springblade.system.pojo.entity.FeeItem; import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.excel.SettlementImportFeeReader; import org.springblade.transport.excel.SettlementImportTemplateExcel; +import org.springblade.transport.excel.SettlementImportTemplateExporter; import org.springblade.transport.mapper.SettlementImportBatchMapper; import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest; import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest; @@ -112,6 +115,13 @@ public class SettlementImportBatchServiceImpl private static final String TYPE_PAYABLE = "payable"; private static final String BILL_TYPE_FORMAL = "formal"; private static final String BILL_TYPE_PRE = "pre"; + private static final String[] VEHICLE_ALIAS_KEYS = { + "*车牌号/航班号/船号/班列号", + "车牌号/航班号/船号/班列号", + "*车牌号/船班号/船次号/班次号", + "车牌号/船班号/船次号/班次号", + "车号" + }; private final IProjectApplyService projectApplyService; private final IContractManageService contractManageService; @@ -186,6 +196,7 @@ public class SettlementImportBatchServiceImpl .eq(SettlementImportBatch::getIsDeleted, 0) .like(Func.isNotEmpty(request.getBatchNo()), SettlementImportBatch::getBatchNo, request.getBatchNo()) .eq(Func.isNotEmpty(request.getSettlementType()), SettlementImportBatch::getSettlementType, request.getSettlementType()) + .eq(Func.isNotEmpty(request.getImportStatus()), SettlementImportBatch::getImportStatus, request.getImportStatus()) .like(Func.isNotEmpty(request.getProjectName()), SettlementImportBatch::getProjectName, request.getProjectName()) .like(Func.isNotEmpty(request.getCustomerName()), SettlementImportBatch::getCustomerName, request.getCustomerName()) .like(Func.isNotEmpty(request.getCarrierName()), SettlementImportBatch::getCarrierName, request.getCarrierName()) @@ -204,13 +215,18 @@ public class SettlementImportBatchServiceImpl if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) { throw new ServiceException("导入结算单批次不存在"); } - return toVO(batch); + SettlementImportBatchVO vo = toVO(batch); + List> rows = parseRowsJson(batch.getRowsJson()); + if (Func.isEmpty(rows) && !STATUS_DRAFT.equals(batch.getImportStatus())) { + rows = buildRowsFromWaybills(listBatchWaybills(batch)); + } + vo.setRows(rows); + return vo; } @Override public void exportTemplate(HttpServletResponse response) { - List sample = new ArrayList<>(); - ExcelUtil.export(response, "导入结算单模板", "导入结算单模板", sample, SettlementImportTemplateExcel.class); + SettlementImportTemplateExporter.export(response, loadTransportTypeLabels(), loadEnabledFeeItemNames()); } @Override @@ -222,6 +238,7 @@ public class SettlementImportBatchServiceImpl Map data = new LinkedHashMap<>(); data.put("rows", result.getRows()); data.put("feeItemNames", result.getFeeItemNames()); + data.put("headers", result.getHeaders()); data.put("warnings", result.getWarnings()); return data; } @@ -254,6 +271,7 @@ public class SettlementImportBatchServiceImpl } List failureList = new ArrayList<>(); for (Map row : request.getRows()) { + keepBlankVehicleNo(row); SettlementImportTemplateExcel excel = mapToExcel(row); excel.setErrorMessage(stringValue(row, "errorMessage", "导入失败原因", "失败原因")); failureList.add(excel); @@ -270,6 +288,7 @@ public class SettlementImportBatchServiceImpl @Override @Transactional(rollbackFor = Exception.class) public SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request) { + prepareSettlementRows(request); SettlementImportBatch batch = buildDraftBatch(request); if (Func.isNotEmpty(request.getId())) { SettlementImportBatch oldBatch = getById(request.getId()); @@ -282,10 +301,14 @@ public class SettlementImportBatchServiceImpl batch.setId(oldBatch.getId()); batch.setBatchNo(oldBatch.getBatchNo()); } - batch.setWaybillCount(0); - batch.setDetailCount(0); + List> draftRows = sanitizeRowsForStorage(request.getRows()); + batch.setRowsJson(JsonUtil.toJson(draftRows)); + batch.setWaybillCount(primaryRows(draftRows).size()); + batch.setDetailCount(draftRows.size()); saveOrUpdate(batch); - return toVO(batch); + SettlementImportBatchVO vo = toVO(batch); + vo.setRows(draftRows); + return vo; } @Override @@ -399,8 +422,73 @@ public class SettlementImportBatchServiceImpl } batch.setImportStatus(STATUS_COMPLETED); batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request))); + batch.setRowsJson(JsonUtil.toJson(sanitizeRowsForStorage(request.getRows()))); updateById(batch); - return toVO(batch); + SettlementImportBatchVO vo = toVO(batch); + vo.setRows(parseRowsJson(batch.getRowsJson())); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeDraft(Long id) { + if (id == null) { + throw new ServiceException("请选择需要删除的批次"); + } + SettlementImportBatch batch = getById(id); + if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) { + throw new ServiceException("导入结算单批次不存在"); + } + if (!STATUS_DRAFT.equals(batch.getImportStatus())) { + throw new ServiceException("仅草稿状态的批次允许删除"); + } + deleteLogic(List.of(id)); + } + + @Override + public List> historyWaybills(SettlementImportBatchRequest request) { + if (request == null || Func.isEmpty(request.getProjectId()) + || Func.isEmpty(request.getCustomerContractId()) + || Func.isEmpty(request.getCarrierContractId())) { + throw new ServiceException("请先选择项目、客户合同和承运商合同"); + } + String settlementType = Func.isEmpty(request.getSettlementType()) + ? TYPE_RECEIVABLE : request.getSettlementType(); + if (!TYPE_RECEIVABLE.equals(settlementType) && !TYPE_PAYABLE.equals(settlementType)) { + throw new ServiceException("结算类型不正确"); + } + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Waybill::getIsDeleted, 0) + .eq(Waybill::getProjectId, request.getProjectId()) + .eq(Waybill::getContractId, request.getCustomerContractId()) + .eq(Waybill::getCarrierContractId, request.getCarrierContractId()) + .apply("not exists (select 1 from blade_receivable_payable_detail d" + + " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}" + + " and d.is_deleted = 0)", settlementType) + .orderByDesc(Waybill::getCreateTime) + .last("limit 500"); + List waybills = waybillService.list(wrapper); + List> result = new ArrayList<>(); + for (Waybill waybill : waybills) { + Map item = new LinkedHashMap<>(); + item.put("id", waybill.getId()); + item.put("waybillNo", waybill.getWaybillNo()); + item.put("vehicleNo", waybill.getVehicleNo()); + item.put("transportType", waybill.getTransportType()); + item.put("departureAddress", waybill.getDepartureAddress()); + item.put("arrivalAddress", waybill.getArrivalAddress()); + item.put("cargoName", waybill.getCargoName()); + item.put("cargoType", waybill.getCargoType()); + item.put("quantity", waybill.getQuantity()); + item.put("quantityUnit", waybill.getQuantityUnit()); + item.put("model", waybill.getModel()); + item.put("mileage", waybill.getMileage()); + item.put("actualStartDate", waybill.getStartDate()); + item.put("actualEndDate", waybill.getEndDate()); + item.put("driverName", waybill.getDriverName()); + result.add(item); + } + return result; } @Override @@ -608,9 +696,9 @@ public class SettlementImportBatchServiceImpl .eq(Waybill::getEndDate, endDate) .eq(Waybill::getDepartureAddress, departureAddress) .eq(Waybill::getArrivalAddress, arrivalAddress) - .eq(Waybill::getCargoName, cargoName) - .orderByDesc(Waybill::getCreateTime) - .last("limit 3"); + .eq(Waybill::getCargoName, cargoName); + applyUnsettledWaybillFilter(wrapper, request.getSettlementType()); + wrapper.orderByDesc(Waybill::getCreateTime).last("limit 3"); List matches = waybillService.list(wrapper); if (Func.isEmpty(matches)) { throw new ServiceException("未匹配到系统已有运单(项目+车号+运输完成日期+客户+承运商+起运地+目的地+货物名称)"); @@ -623,16 +711,17 @@ public class SettlementImportBatchServiceImpl /** 手工指定运单号时,在当前项目/客户/承运商范围内精确匹配。 */ private Waybill matchHistoryWaybillByNo(SettlementImportBatchRequest request, String waybillNo) { - List matches = waybillService.list(Wrappers.lambdaQuery() + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(Waybill::getIsDeleted, 0) .eq(Waybill::getProjectId, request.getProjectId()) .eq(Waybill::getContractId, request.getCustomerContractId()) .eq(Waybill::getCarrierContractId, request.getCarrierContractId()) - .eq(Waybill::getWaybillNo, waybillNo) - .orderByDesc(Waybill::getCreateTime) - .last("limit 2")); + .eq(Waybill::getWaybillNo, waybillNo); + applyUnsettledWaybillFilter(wrapper, request.getSettlementType()); + wrapper.orderByDesc(Waybill::getCreateTime).last("limit 2"); + List matches = waybillService.list(wrapper); if (Func.isEmpty(matches)) { - throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的系统已有运单"); + throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的未结算运单"); } if (matches.size() > 1) { throw new ServiceException("运单号「" + waybillNo + "」匹配到多条运单,请核对后重试"); @@ -640,6 +729,14 @@ public class SettlementImportBatchServiceImpl return matches.get(0); } + /** 仅保留尚未生成对应结算类型应收/应付明细的运单。 */ + private void applyUnsettledWaybillFilter(LambdaQueryWrapper wrapper, String settlementType) { + String type = Func.isEmpty(settlementType) ? TYPE_RECEIVABLE : settlementType; + wrapper.apply("not exists (select 1 from blade_receivable_payable_detail d" + + " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}" + + " and d.is_deleted = 0)", type); + } + private void prepareSettlementRows(SettlementImportBatchRequest request) { if (request == null || Func.isEmpty(request.getRows())) { return; @@ -649,6 +746,7 @@ public class SettlementImportBatchServiceImpl if (row == null) { continue; } + keepBlankVehicleNo(row); BigDecimal transportFee = decimalValue(row, "transportFee", "*运输费", "运输费", "freight", "运费"); if (transportFee == null) { transportFee = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); @@ -656,7 +754,12 @@ public class SettlementImportBatchServiceImpl Map feeItems = extractFeeItems(row, feeItemNames); BigDecimal feeItemTotal = feeItems.values().stream() .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); - BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP); + BigDecimal settlementAmount = decimalValue(row, "settlementAmount", "结算金额"); + if (settlementAmount == null) { + settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP); + } else { + settlementAmount = settlementAmount.setScale(2, RoundingMode.HALF_UP); + } row.put("transportFee", transportFee); row.put("feeItems", feeItems); row.put("settlementAmount", settlementAmount); @@ -703,7 +806,9 @@ public class SettlementImportBatchServiceImpl private Map validateSettlementExtras(SettlementImportBatchRequest request) { Map errorMap = new LinkedHashMap<>(); - Set feeItemCatalog = loadFeeItemNames(); + Set enabledFeeItemNames = new HashSet<>(); + Set disabledFeeItemNames = new HashSet<>(); + loadFeeItemNames(enabledFeeItemNames, disabledFeeItemNames); List feeItemNames = resolveFeeItemNames(request); List> rows = request.getRows(); for (int index = 0; index < rows.size(); index++) { @@ -716,15 +821,21 @@ public class SettlementImportBatchServiceImpl errors.add("*运输费必须大于等于0"); } Map feeItems = extractFeeItems(row, feeItemNames); - for (String name : feeItems.keySet()) { - if (!feeItemCatalog.contains(name)) { - errors.add("费用项「" + name + "」未在费用项主数据中匹配"); + for (Map.Entry entry : feeItems.entrySet()) { + String name = normalizeFeeName(entry.getKey()); + BigDecimal amount = entry.getValue() == null ? BigDecimal.ZERO : entry.getValue(); + // 模板自带的装卸费、过路费等列未填写时金额为 0,不要求费用项主数据存在同名档案 + if (Func.isEmpty(name) || amount.compareTo(BigDecimal.ZERO) == 0) { + continue; } - } - for (String name : feeItemNames) { - if (Func.isNotEmpty(name) && !feeItemCatalog.contains(name)) { - errors.add("费用项「" + name + "」未在费用项主数据中匹配"); + if (enabledFeeItemNames.contains(name)) { + continue; } + if (disabledFeeItemNames.contains(name)) { + errors.add("费用项「" + name + "」已停用"); + continue; + } + errors.add("费用项「" + name + "」未在费用项主数据中匹配"); } if (!errors.isEmpty()) { errorMap.put(index, String.join("; ", errors)); @@ -733,15 +844,27 @@ public class SettlementImportBatchServiceImpl return errorMap; } - private Set loadFeeItemNames() { - R> response = sysClient.getFeeItems(); + private void loadFeeItemNames(Set enabledFeeItemNames, Set disabledFeeItemNames) { + R> response = sysClient.getFeeItems(true); if (response == null || !response.isSuccess() || response.getData() == null) { throw new ServiceException("费用项基础档案读取失败,请稍后重试"); } - return response.getData().stream() - .map(FeeItem::getName) - .filter(Func::isNotEmpty) - .collect(Collectors.toCollection(HashSet::new)); + for (FeeItem feeItem : response.getData()) { + String name = normalizeFeeName(feeItem.getName()); + if (Func.isEmpty(name)) { + continue; + } + if (feeItem.getStatus() == null || Objects.equals(feeItem.getStatus(), 1)) { + enabledFeeItemNames.add(name); + } else { + disabledFeeItemNames.add(name); + } + } + disabledFeeItemNames.removeAll(enabledFeeItemNames); + } + + private String normalizeFeeName(String name) { + return name == null ? "" : name.trim(); } private List resolveFeeItemNames(SettlementImportBatchRequest request) { @@ -1006,7 +1129,7 @@ public class SettlementImportBatchServiceImpl excel.setSerialNumber(intValue(row.get("serialNumber"))); excel.setOriginalNo(stringValue(row, "originalNo", "原始单号")); excel.setLoadingIdentifier(stringValue(row, "loadingIdentifier", "配载标识号")); - excel.setVehicleNo(stringValue(row, "vehicleNo", "*车牌号/船班号/船次号/班次号")); + excel.setVehicleNo(vehicleNoValue(row)); excel.setTransportType(stringValue(row, "transportType", "*运输方式", "运输方式")); excel.setDriverName(stringValue(row, "driverName", "司机/船长姓名")); excel.setDriverPhone(stringValue(row, "driverPhone", "司机/船长手机号")); @@ -1039,6 +1162,30 @@ public class SettlementImportBatchServiceImpl return excel; } + /** + * 车号以 vehicleNo 为准。单元格没填时不再回退到表头别名,避免失败明细和落库被自动补上车号。 + */ + private void keepBlankVehicleNo(Map row) { + if (row == null || !row.containsKey("vehicleNo")) { + return; + } + String vehicleNo = stringValue(row, "vehicleNo"); + row.put("vehicleNo", vehicleNo); + if (Func.isNotEmpty(vehicleNo)) { + return; + } + for (String alias : VEHICLE_ALIAS_KEYS) { + row.put(alias, null); + } + } + + private String vehicleNoValue(Map row) { + if (row != null && row.containsKey("vehicleNo")) { + return stringValue(row, "vehicleNo"); + } + return stringValue(row, "vehicleNo", VEHICLE_ALIAS_KEYS); + } + private Map option(Object id, String nameKey, Object nameValue) { return option(id, nameKey, nameValue, null, null); } @@ -1141,4 +1288,130 @@ public class SettlementImportBatchServiceImpl String merged = Func.isEmpty(original) ? "作废原因:" + reason : original + ";作废原因:" + reason; return limit(merged, 200); } + + @SuppressWarnings("unchecked") + private List> parseRowsJson(String rowsJson) { + if (Func.isEmpty(rowsJson)) { + return List.of(); + } + List rawList = JsonUtil.parseArray(rowsJson, Map.class); + if (rawList == null || rawList.isEmpty()) { + return List.of(); + } + List> rows = new ArrayList<>(); + for (Map raw : rawList) { + if (raw == null) { + continue; + } + rows.add(new LinkedHashMap<>(raw)); + } + return rows; + } + + private List> sanitizeRowsForStorage(List> rows) { + if (Func.isEmpty(rows)) { + return List.of(); + } + List> sanitized = new ArrayList<>(); + for (Map row : rows) { + if (row == null) { + continue; + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : row.entrySet()) { + String key = entry.getKey(); + if (key == null || key.startsWith("_")) { + continue; + } + copy.put(key, entry.getValue()); + } + sanitized.add(copy); + } + return sanitized; + } + + private List loadTransportTypeLabels() { + LinkedHashSet labels = new LinkedHashSet<>(); + try { + List dictBizList = DictBizCache.getList("transport_type"); + if (Func.isNotEmpty(dictBizList)) { + dictBizList.stream() + .map(DictBiz::getDictValue) + .filter(Func::isNotEmpty) + .map(String::trim) + .forEach(labels::add); + } + } catch (Exception ignored) { + // 业务字典不可用时继续读取系统字典 + } + try { + List dictList = DictCache.getList("transport_type"); + if (Func.isNotEmpty(dictList)) { + dictList.stream() + .map(org.springblade.system.pojo.entity.Dict::getDictValue) + .filter(Func::isNotEmpty) + .map(String::trim) + .forEach(labels::add); + } + } catch (Exception ignored) { + // 忽略 + } + if (labels.isEmpty()) { + return List.of("公路运输", "铁路运输", "水路运输", "航空运输"); + } + return new ArrayList<>(labels); + } + + private List loadEnabledFeeItemNames() { + R> response = sysClient.getFeeItems(false); + if (response == null || !response.isSuccess() || response.getData() == null) { + return List.of(); + } + return response.getData().stream() + .filter(item -> item != null && (item.getStatus() == null || Objects.equals(item.getStatus(), 1))) + .map(FeeItem::getName) + .filter(Func::isNotEmpty) + .map(String::trim) + .distinct() + .toList(); + } + + /** 历史已完成批次无 rows_json 时,按关联运单回填查看明细。 */ + private List> buildRowsFromWaybills(List waybills) { + if (Func.isEmpty(waybills)) { + return List.of(); + } + List> rows = new ArrayList<>(); + int serial = 1; + for (Waybill waybill : waybills) { + Map row = new LinkedHashMap<>(); + row.put("serialNumber", serial++); + row.put("waybillNo", waybill.getWaybillNo()); + row.put("vehicleNo", waybill.getVehicleNo()); + row.put("transportType", waybill.getTransportType()); + row.put("driverName", waybill.getDriverName()); + row.put("driverPhone", waybill.getDriverPhone()); + row.put("departureAddress", waybill.getDepartureAddress()); + row.put("departureContact", waybill.getDepartureContact()); + row.put("departurePhone", waybill.getDeparturePhone()); + row.put("arrivalAddress", waybill.getArrivalAddress()); + row.put("arrivalContact", waybill.getArrivalContact()); + row.put("arrivalPhone", waybill.getArrivalPhone()); + row.put("originalNo", waybill.getOriginalNo()); + row.put("loadingIdentifier", waybill.getLoadingIdentifier()); + row.put("cargoName", waybill.getCargoName()); + row.put("cargoType", waybill.getCargoType()); + row.put("quantity", waybill.getQuantity()); + row.put("quantityUnit", waybill.getQuantityUnit()); + row.put("specification", waybill.getSpecification()); + row.put("model", waybill.getModel()); + row.put("mileage", waybill.getMileage()); + row.put("unitPrice", waybill.getUnitPrice()); + row.put("actualStartDate", waybill.getStartDate()); + row.put("actualEndDate", waybill.getEndDate()); + row.put("remark", waybill.getRemark()); + rows.add(row); + } + return rows; + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java index 52e7dbc4..473ee3d3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java @@ -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 options = new ArrayList<>(); @@ -121,6 +126,20 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP return money(writeOffMapper.sumWriteOffBySettlement(settlementType, settlementId)); } + @Override + public Map sumBySettlementIds(String settlementType, Collection settlementIds) { + if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) { + return Map.of(); + } + Map result = new HashMap<>(); + for (Map 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 settlementIds) { + if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) { + return 0; + } + return writeOffMapper.delete(Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .in(SettlementProjectAdvanceWriteOff::getSettlementId, settlementIds)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List deleteByPaymentApplicationIds(Collection paymentApplicationIds) { + if (Func.isEmpty(paymentApplicationIds)) { + return List.of(); + } + List rows = writeOffMapper.selectList( + Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0) + .in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds)); + if (rows.isEmpty()) { + return List.of(); + } + writeOffMapper.delete(Wrappers.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); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementRuleAutoGenerateServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementRuleAutoGenerateServiceImpl.java new file mode 100644 index 00000000..67818e9e --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementRuleAutoGenerateServiceImpl.java @@ -0,0 +1,622 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tenant.TenantUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.mapper.PreSettlementDetailMapper; +import org.springblade.transport.mapper.ReceivablePayableDetailMapper; +import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest; +import org.springblade.transport.pojo.dto.PreSettlementSaveRequest; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.PreSettlement; +import org.springblade.transport.pojo.entity.PreSettlementDetail; +import org.springblade.transport.pojo.entity.ReceivablePayableDetail; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IPreSettlementService; +import org.springblade.transport.service.ISettlementRuleAutoGenerateService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.YearMonth; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 按合同结算规则自动生成结算草稿。 + *

+ * 账期在次日零点视为关闭:日结/现结取前一日,周结和固定天数从账单开始日期顺延, + * 月结按自然月、固定截单日或自定义多周期截取。没有可归集明细时不生成空单。 + * 同一明细只进入一张结算单,预结算先于正式结算执行。正式结算同时归集费用日期完整落在账期内、且尚未转正式结算的已审批预结算单。 + * + * @author Chill + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SettlementRuleAutoGenerateServiceImpl implements ISettlementRuleAutoGenerateService { + + private static final int QUERY_BATCH_SIZE = 500; + + private static final String LOCAL_CURRENCY = "RMB"; + + private final IContractManageService contractManageService; + + private final IPreSettlementService preSettlementService; + + private final IFormalSettlementService formalSettlementService; + + private final ReceivablePayableDetailMapper receivablePayableDetailMapper; + + private final PreSettlementDetailMapper preSettlementDetailMapper; + + private final PlatformTransactionManager transactionManager; + + @Override + public synchronized String generateDueSettlements() { + LocalDate today = LocalDate.now(); + List contracts = TenantUtil.ignore(this::listAutoContracts); + int preCount = 0; + int formalCount = 0; + int failedCount = 0; + for (ContractManage contract : contracts) { + if (Func.isBlank(contract.getTenantId())) { + log.warn("合同[{}]缺少租户,跳过自动生成结算单", contract.getContractNo()); + continue; + } + try { + int[] created = TenantUtil.use(contract.getTenantId(), () -> generateContract(contract, today)); + preCount += created[0]; + formalCount += created[1]; + } catch (Exception exception) { + failedCount++; + log.error("合同[{}]自动生成结算单失败", contract.getContractNo(), exception); + } + } + return "扫描合同" + contracts.size() + "份,生成预结算单" + preCount + "张,正式结算单" + formalCount + + "张,失败" + failedCount + "份"; + } + + private List listAutoContracts() { + return contractManageService.list(Wrappers.lambdaQuery() + .in(ContractManage::getApprovalStatus, "approved", "change_approved") + .in(ContractManage::getContractCategory, "客户合同", "承运商合同") + .and(query -> query.isNull(ContractManage::getContractStage) + .or().ne(ContractManage::getContractStage, "terminated")) + .and(query -> query.like(ContractManage::getPreSettlementConfigJson, "autoGenerate") + .or().like(ContractManage::getFormalSettlementConfigJson, "autoGenerate") + .or().like(ContractManage::getSettlementRuleJson, "autoGenerate"))); + } + + private int[] generateContract(ContractManage contract, LocalDate today) { + String settlementType = contractSettlementType(contract); + if (settlementType == null || contract.getProjectId() == null || contract.getOrganizationId() == null) { + log.warn("合同[{}]缺少结算类型、项目或所属组织,跳过自动生成", contract.getContractNo()); + return new int[]{0, 0}; + } + int preCount = generateBills(contract, settlementType, resolveRule(contract, true), today, true); + int formalCount = generateBills(contract, settlementType, resolveRule(contract, false), today, false); + return new int[]{preCount, formalCount}; + } + + private int generateBills(ContractManage contract, String settlementType, Map rule, + LocalDate today, boolean preBill) { + if (!enabled(rule.get("autoGenerate"))) { + return 0; + } + String cycleName = Func.toStr(rule.get("settlementType")).trim(); + if (!knownCycle(cycleName)) { + log.warn("合同[{}]{}结算周期[{}]无法识别,跳过自动生成", contract.getContractNo(), + preBill ? "预" : "正式", cycleName); + return 0; + } + List ranges = closedPeriods(rule, today); + if (ranges.isEmpty()) { + return 0; + } + if (preBill) { + return createPreBills(contract, ranges); + } + return createFormalBills(contract, settlementType, ranges); + } + + private int createPreBills(ContractManage contract, List ranges) { + List details = loadPendingDetails(contract, ranges); + Map> grouped = groupDetails(ranges, details); + int created = 0; + for (Map.Entry> entry : grouped.entrySet()) { + Map> byCurrency = new LinkedHashMap<>(); + for (ReceivablePayableDetail detail : entry.getValue()) { + String currency = Func.isEmpty(detail.getCurrency()) ? LOCAL_CURRENCY : detail.getCurrency(); + byCurrency.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId()); + } + for (List detailIds : byCurrency.values()) { + try { + inTransaction(() -> { + PreSettlementSaveRequest request = new PreSettlementSaveRequest(); + request.setContractId(contract.getId()); + request.setSourceDetailIds(detailIds); + request.setRemark(periodRemark(entry.getKey())); + preSettlementService.saveDraft(request); + }); + created++; + log.info("合同[{}]自动生成预结算单,账期{}至{},明细{}条", contract.getContractNo(), + entry.getKey().start(), entry.getKey().end(), detailIds.size()); + } catch (Exception exception) { + log.error("合同[{}]自动生成预结算单失败,账期{}至{}", contract.getContractNo(), + entry.getKey().start(), entry.getKey().end(), exception); + } + } + } + return created; + } + + private int createFormalBills(ContractManage contract, String settlementType, List ranges) { + Map> detailGroups = groupDetails(ranges, loadPendingDetails(contract, ranges)); + Map> preGroups = groupApprovedPres(contract, settlementType, ranges); + int created = 0; + for (DateRange range : ranges) { + Map> detailIds = new LinkedHashMap<>(); + for (ReceivablePayableDetail detail : detailGroups.getOrDefault(range, List.of())) { + String currency = detail.getCurrency() == null ? "" : detail.getCurrency(); + detailIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId()); + } + Map> preIds = new LinkedHashMap<>(); + for (PreSettlement preSettlement : preGroups.getOrDefault(range, List.of())) { + String currency = preSettlement.getCurrency() == null ? "" : preSettlement.getCurrency(); + preIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(preSettlement.getId()); + } + Set currencies = new LinkedHashSet<>(); + currencies.addAll(detailIds.keySet()); + currencies.addAll(preIds.keySet()); + for (String currency : currencies) { + List sourcePreSettlementIds = preIds.getOrDefault(currency, List.of()); + List sourceDetailIds = detailIds.getOrDefault(currency, List.of()); + if (sourcePreSettlementIds.isEmpty() && sourceDetailIds.isEmpty()) { + continue; + } + try { + inTransaction(() -> { + FormalSettlementSaveRequest request = new FormalSettlementSaveRequest(); + request.setContractId(contract.getId()); + request.setSettlementType(settlementType); + request.setSourcePreSettlementIds(sourcePreSettlementIds.isEmpty() ? null : sourcePreSettlementIds); + request.setSourceDetailIds(sourceDetailIds.isEmpty() ? null : sourceDetailIds); + request.setRemark(periodRemark(range)); + formalSettlementService.saveDraft(request); + }); + created++; + log.info("合同[{}]自动生成正式结算单,账期{}至{},预结算{}张,明细{}条", contract.getContractNo(), + range.start(), range.end(), sourcePreSettlementIds.size(), sourceDetailIds.size()); + } catch (Exception exception) { + log.error("合同[{}]自动生成正式结算单失败,账期{}至{}", contract.getContractNo(), + range.start(), range.end(), exception); + } + } + } + return created; + } + + private List loadPendingDetails(ContractManage contract, List ranges) { + LocalDate minDate = ranges.get(0).start(); + LocalDate maxDate = ranges.get(0).end(); + for (DateRange range : ranges) { + if (range.start().isBefore(minDate)) { + minDate = range.start(); + } + if (range.end().isAfter(maxDate)) { + maxDate = range.end(); + } + } + LocalDate periodStart = minDate; + LocalDate periodEnd = maxDate; + return receivablePayableDetailMapper.selectList(Wrappers.lambdaQuery() + .eq(ReceivablePayableDetail::getIsDeleted, 0) + .eq(ReceivablePayableDetail::getContractId, contract.getId()) + .eq(ReceivablePayableDetail::getProjectId, contract.getProjectId()) + .eq(ReceivablePayableDetail::getDeptId, contract.getOrganizationId()) + .eq(ReceivablePayableDetail::getSettlementType, contractSettlementType(contract)) + .eq(ReceivablePayableDetail::getSettlementStatus, "pending") + .and(query -> query.isNull(ReceivablePayableDetail::getPreSettlementNo) + .or().eq(ReceivablePayableDetail::getPreSettlementNo, "")) + .and(query -> query.isNull(ReceivablePayableDetail::getFormalSettlementNo) + .or().eq(ReceivablePayableDetail::getFormalSettlementNo, "")) + .and(query -> query.eq(ReceivablePayableDetail::getCustomerName, contract.getPartyA()) + .or().eq(ReceivablePayableDetail::getCustomerName, contract.getPartyB())) + .and(query -> query.between(ReceivablePayableDetail::getFeeDate, periodStart, periodEnd) + .or(nested -> nested.isNull(ReceivablePayableDetail::getFeeDate) + .ge(ReceivablePayableDetail::getCreateTime, periodStart.atStartOfDay()) + .lt(ReceivablePayableDetail::getCreateTime, periodEnd.plusDays(1).atStartOfDay())))); + } + + private Map> groupApprovedPres(ContractManage contract, String settlementType, + List ranges) { + Map> grouped = new LinkedHashMap<>(); + ranges.forEach(range -> grouped.put(range, new ArrayList<>())); + List preSettlements = preSettlementService.list(Wrappers.lambdaQuery() + .eq(PreSettlement::getContractId, contract.getId()) + .eq(PreSettlement::getSettlementType, settlementType) + .eq(PreSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) + .and(query -> query.isNull(PreSettlement::getFormalSettlementNo) + .or().eq(PreSettlement::getFormalSettlementNo, ""))); + if (preSettlements.isEmpty()) { + return grouped; + } + List preSettlementIds = preSettlements.stream().map(PreSettlement::getId).toList(); + Map> sourceIdsByPre = new LinkedHashMap<>(); + for (List batch : partition(preSettlementIds)) { + List lines = preSettlementDetailMapper.selectList(Wrappers.lambdaQuery() + .in(PreSettlementDetail::getPreSettlementId, batch) + .eq(PreSettlementDetail::getIsDeleted, 0)); + for (PreSettlementDetail line : lines) { + if (line.getSourceDetailId() == null) { + continue; + } + sourceIdsByPre.computeIfAbsent(line.getPreSettlementId(), key -> new ArrayList<>()).add(line.getSourceDetailId()); + } + } + Set sourceIds = new LinkedHashSet<>(); + sourceIdsByPre.values().forEach(sourceIds::addAll); + Map sourceMap = new LinkedHashMap<>(); + for (List batch : partition(sourceIds)) { + receivablePayableDetailMapper.selectBatchIds(batch) + .forEach(detail -> sourceMap.put(detail.getId(), detail)); + } + for (PreSettlement preSettlement : preSettlements) { + List sourceDetailIds = sourceIdsByPre.getOrDefault(preSettlement.getId(), List.of()); + LocalDate minDate = null; + LocalDate maxDate = null; + boolean invalid = sourceDetailIds.isEmpty(); + for (Long sourceDetailId : sourceDetailIds) { + ReceivablePayableDetail source = sourceMap.get(sourceDetailId); + LocalDate businessDate = source == null ? null : businessDate(source); + if (businessDate == null) { + invalid = true; + break; + } + minDate = minDate == null || businessDate.isBefore(minDate) ? businessDate : minDate; + maxDate = maxDate == null || businessDate.isAfter(maxDate) ? businessDate : maxDate; + } + if (invalid || minDate == null || maxDate == null) { + log.warn("预结算单[{}]缺少可归集的费用日期,跳过自动转正式结算", preSettlement.getPreSettlementNo()); + continue; + } + boolean matched = false; + for (DateRange range : ranges) { + if (!minDate.isBefore(range.start()) && !maxDate.isAfter(range.end())) { + grouped.get(range).add(preSettlement); + matched = true; + break; + } + } + if (!matched && !maxDate.isAfter(LocalDate.now().minusDays(1))) { + log.info("预结算单[{}]费用日期{}至{}未完整落入已关闭账期,暂不自动归集正式结算", + preSettlement.getPreSettlementNo(), minDate, maxDate); + } + } + return grouped; + } + + private Map> groupDetails(List ranges, + List details) { + Map> grouped = new LinkedHashMap<>(); + ranges.forEach(range -> grouped.put(range, new ArrayList<>())); + for (ReceivablePayableDetail detail : details) { + LocalDate businessDate = businessDate(detail); + if (businessDate == null) { + continue; + } + for (DateRange range : ranges) { + if (!businessDate.isBefore(range.start()) && !businessDate.isAfter(range.end())) { + grouped.get(range).add(detail); + break; + } + } + } + return grouped; + } + + private List closedPeriods(Map rule, LocalDate today) { + LocalDate billStart = dateValue(rule.get("billStartDate")); + LocalDate yesterday = today.minusDays(1); + if (billStart == null || yesterday.isBefore(billStart)) { + return List.of(); + } + String settlementType = Func.toStr(rule.get("settlementType")).trim(); + return switch (settlementType) { + case "现结", "日结" -> cyclePeriods(billStart, yesterday, 1); + case "周结" -> cyclePeriods(billStart, yesterday, 7); + case "按固定天数周期", "固定天数周期结算" -> { + Integer cycleDays = intValue(rule.get("cycleDays")); + if (cycleDays == null || cycleDays < 1 || cycleDays > 365) { + yield List.of(); + } + yield cyclePeriods(billStart, yesterday, cycleDays); + } + case "月结" -> monthlyPeriods(rule, billStart, yesterday); + default -> List.of(); + }; + } + + private List monthlyPeriods(Map rule, LocalDate billStart, LocalDate yesterday) { + String cycleType = Func.toStr(rule.get("billCycleType")).trim(); + if (Func.isBlank(cycleType) || "自然月".equals(cycleType)) { + return naturalMonthPeriods(billStart, yesterday); + } + if ("固定截单日".equals(cycleType)) { + Integer cutoffDay = intValue(rule.get("billCutoffDay")); + if (cutoffDay == null || cutoffDay < 1 || cutoffDay > 31) { + return List.of(); + } + return fixedCutoffPeriods(billStart, yesterday, cutoffDay); + } + if ("自定义多周期".equals(cycleType)) { + return customMonthPeriods(rule.get("customPeriods"), billStart, yesterday); + } + return List.of(); + } + + private List cyclePeriods(LocalDate billStart, LocalDate yesterday, int days) { + List ranges = new ArrayList<>(); + LocalDate cursor = billStart; + while (true) { + LocalDate end = cursor.plusDays(days - 1L); + if (end.isAfter(yesterday)) { + break; + } + ranges.add(new DateRange(cursor, end)); + cursor = end.plusDays(1); + } + return ranges; + } + + private List naturalMonthPeriods(LocalDate billStart, LocalDate yesterday) { + List ranges = new ArrayList<>(); + YearMonth cursor = YearMonth.from(billStart); + YearMonth lastMonth = YearMonth.from(yesterday); + while (!cursor.isAfter(lastMonth)) { + LocalDate end = cursor.atEndOfMonth(); + if (!end.isAfter(yesterday) && !end.isBefore(billStart)) { + LocalDate start = cursor.atDay(1).isBefore(billStart) ? billStart : cursor.atDay(1); + ranges.add(new DateRange(start, end)); + } + cursor = cursor.plusMonths(1); + } + return ranges; + } + + private List fixedCutoffPeriods(LocalDate billStart, LocalDate yesterday, int cutoffDay) { + List ranges = new ArrayList<>(); + YearMonth cursor = YearMonth.from(billStart).minusMonths(1); + YearMonth limit = YearMonth.from(yesterday).plusMonths(1); + LocalDate previousEnd = null; + while (!cursor.isAfter(limit)) { + LocalDate end = cursor.atDay(Math.min(cutoffDay, cursor.lengthOfMonth())); + if (!end.isBefore(billStart) && !end.isAfter(yesterday)) { + LocalDate start = previousEnd == null ? billStart : previousEnd.plusDays(1); + if (start.isBefore(billStart)) { + start = billStart; + } + if (!start.isAfter(end)) { + ranges.add(new DateRange(start, end)); + } + } + if (!end.isBefore(billStart.minusMonths(1))) { + previousEnd = end; + } + cursor = cursor.plusMonths(1); + } + return ranges; + } + + private List customMonthPeriods(Object periodsValue, LocalDate billStart, LocalDate yesterday) { + List periods = customPeriods(periodsValue); + if (periods.isEmpty()) { + return List.of(); + } + List ranges = new ArrayList<>(); + YearMonth cursor = YearMonth.from(billStart); + YearMonth lastMonth = YearMonth.from(yesterday); + while (!cursor.isAfter(lastMonth)) { + for (int[] period : periods) { + int startDay = Math.min(period[0], cursor.lengthOfMonth()); + int endDay = Math.min(period[1], cursor.lengthOfMonth()); + if (startDay > endDay) { + continue; + } + LocalDate end = cursor.atDay(endDay); + if (end.isAfter(yesterday) || end.isBefore(billStart)) { + continue; + } + LocalDate start = cursor.atDay(startDay); + if (start.isBefore(billStart)) { + start = billStart; + } + if (!start.isAfter(end)) { + ranges.add(new DateRange(start, end)); + } + } + cursor = cursor.plusMonths(1); + } + return ranges; + } + + private List customPeriods(Object value) { + if (!(value instanceof List periodList)) { + return List.of(); + } + List periods = new ArrayList<>(); + for (Object item : periodList) { + if (!(item instanceof Map period)) { + continue; + } + Integer startDay = intValue(period.get("startDay")); + Integer endDay = intValue(period.get("endDay")); + if (startDay == null || endDay == null || startDay < 1 || endDay > 31 || endDay < startDay) { + continue; + } + periods.add(new int[]{startDay, endDay}); + } + return periods; + } + + private Map resolveRule(ContractManage contract, boolean preRule) { + Map direct = parseObject(preRule ? contract.getPreSettlementConfigJson() : contract.getFormalSettlementConfigJson()); + if (!direct.isEmpty()) { + return direct; + } + Map payload = parseObject(contract.getSettlementRuleJson()); + Object nested = payload.get(preRule ? "preSettlementConfig" : "formalSettlementConfig"); + if (nested instanceof Map nestedMap) { + Map result = new LinkedHashMap<>(); + nestedMap.forEach((key, value) -> result.put(String.valueOf(key), value)); + return result; + } + if (!payload.containsKey("preSettlementConfig") && !payload.containsKey("formalSettlementConfig")) { + return payload; + } + return Map.of(); + } + + private Map parseObject(String json) { + if (Func.isBlank(json)) { + return Map.of(); + } + try { + Object parsed = JsonUtil.parse(json, Map.class); + if (!(parsed instanceof Map raw) || raw.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + raw.forEach((key, value) -> result.put(String.valueOf(key), value)); + return result; + } catch (Exception exception) { + log.warn("结算规则JSON解析失败:{}", exception.getMessage()); + return Map.of(); + } + } + + private void inTransaction(Runnable runnable) { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> runnable.run()); + } + + private List> partition(Collection ids) { + List source = ids instanceof List list ? list : new ArrayList<>(ids); + List> batches = new ArrayList<>(); + for (int index = 0; index < source.size(); index += QUERY_BATCH_SIZE) { + batches.add(source.subList(index, Math.min(index + QUERY_BATCH_SIZE, source.size()))); + } + return batches; + } + + private String contractSettlementType(ContractManage contract) { + if ("客户合同".equals(contract.getContractCategory())) { + return "receivable"; + } + if ("承运商合同".equals(contract.getContractCategory())) { + return "payable"; + } + return null; + } + + private LocalDate businessDate(ReceivablePayableDetail detail) { + if (detail.getFeeDate() != null) { + return detail.getFeeDate(); + } + if (detail.getCreateTime() == null) { + return null; + } + return detail.getCreateTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + + private String periodRemark(DateRange range) { + return "系统按结算规则自动生成(" + range.start() + "至" + range.end() + ")"; + } + + private boolean enabled(Object value) { + if (value instanceof Boolean enabled) { + return enabled; + } + if (value instanceof Number number) { + return number.intValue() == 1; + } + String text = value == null ? "" : String.valueOf(value).trim(); + return "1".equals(text) || "true".equalsIgnoreCase(text); + } + + private boolean knownCycle(String settlementType) { + return List.of("现结", "日结", "周结", "月结", "按固定天数周期", "固定天数周期结算").contains(settlementType); + } + + private Integer intValue(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value == null || Func.isBlank(String.valueOf(value))) { + return null; + } + try { + return Integer.parseInt(String.valueOf(value).trim()); + } catch (NumberFormatException exception) { + return null; + } + } + + private LocalDate dateValue(Object value) { + if (value == null || Func.isBlank(String.valueOf(value))) { + return null; + } + String text = String.valueOf(value).trim(); + if (text.length() >= 10) { + text = text.substring(0, 10); + } + try { + return LocalDate.parse(text); + } catch (DateTimeParseException exception) { + return null; + } + } + + private record DateRange(LocalDate start, LocalDate end) { + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java index b75e757a..46e7e20e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java @@ -246,10 +246,8 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl assertAllMatched(TransportReconciliation bill) { List internals = internalRows(bill.getId()); List externals = externalRows(bill.getId()); - if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size() - || internals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchResult())) - || externals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchStatus()) || Boolean.TRUE.equals(item.getSuspectedDuplicate()))) { + // 调整账单后旧的疑似重复标记可能已失效,按当前内容重算,避免已一一匹配仍被拦截 + syncSuspectedDuplicates(externals); + if (!oneToOneMatched(internals, externals)) { throw new ServiceException("所有内外部账单明细必须一一匹配且不存在疑似重复"); } return internals; } + private void syncSuspectedDuplicates(List externals) { + Map> groups = externals.stream() + .collect(Collectors.groupingBy(this::duplicateKey)); + Set duplicateIds = new HashSet<>(); + for (List group : groups.values()) { + if (group.size() > 1) { + group.stream().map(TransportReconciliationExternal::getId).forEach(duplicateIds::add); + } + } + for (TransportReconciliationExternal external : externals) { + boolean duplicate = duplicateIds.contains(external.getId()); + String matchStatus = external.getMatchStatus(); + if (!duplicate && DUPLICATE.equals(matchStatus) && external.getMatchedInternalId() != null) { + matchStatus = MATCHED; + } + if (Boolean.TRUE.equals(external.getSuspectedDuplicate()) == duplicate + && Objects.equals(matchStatus, external.getMatchStatus())) { + continue; + } + external.setSuspectedDuplicate(duplicate); + external.setMatchStatus(matchStatus); + externalMapper.update(null, Wrappers.lambdaUpdate() + .eq(TransportReconciliationExternal::getId, external.getId()) + .set(TransportReconciliationExternal::getSuspectedDuplicate, duplicate) + .set(TransportReconciliationExternal::getMatchStatus, matchStatus)); + } + } + + private boolean oneToOneMatched(List internals, + List externals) { + if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size()) { + return false; + } + if (externals.stream().anyMatch(item -> Boolean.TRUE.equals(item.getSuspectedDuplicate()))) { + return false; + } + Map externalMap = externals.stream() + .collect(Collectors.toMap(TransportReconciliationExternal::getId, Function.identity(), (left, right) -> left)); + Set usedExternalIds = new HashSet<>(); + for (TransportReconciliationInternal internal : internals) { + if (!MATCHED.equals(internal.getMatchResult()) || internal.getMatchedExternalId() == null + || !usedExternalIds.add(internal.getMatchedExternalId())) { + return false; + } + TransportReconciliationExternal external = externalMap.get(internal.getMatchedExternalId()); + if (external == null || !MATCHED.equals(external.getMatchStatus()) + || !Objects.equals(external.getMatchedInternalId(), internal.getId())) { + return false; + } + } + return usedExternalIds.size() == externals.size(); + } + private void refreshStats(Long id) { TransportReconciliation bill = existing(id); List internals = internalRows(id); @@ -1459,20 +1512,14 @@ public class TransportReconciliationServiceImpl private BigDecimal sumExternalQuantity(List rows) { return rows.stream().map(TransportReconciliationExternal::getTransportQuantity).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } private BigDecimal sumInternalAmount(List rows) { return rows.stream().map(TransportReconciliationInternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } private BigDecimal sumExternalAmount(List rows) { return rows.stream().map(TransportReconciliationExternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } + /** 生成当日运输对账单号;按最大流水号 +1,且计入逻辑删除数据 */ private synchronized String nextNo() { String prefix = "DZD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - int sequence = list(Wrappers.lambdaQuery() - .select(TransportReconciliation::getReconciliationNo) - .likeRight(TransportReconciliation::getReconciliationNo, prefix)) - .stream() - .map(TransportReconciliation::getReconciliationNo) - .filter(number -> number != null && number.length() == prefix.length() + 5) - .map(number -> number.substring(prefix.length())) - .filter(suffix -> suffix.chars().allMatch(Character::isDigit)) - .mapToInt(Integer::parseInt) - .max() - .orElse(0) + 1; - if (sequence > 99999) throw new ServiceException("当日运输对账单号流水已用完"); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + long sequence = (maxSequence == null ? 0L : maxSequence) + 1; + if (sequence > 99999) { + throw new ServiceException("当日运输对账单号流水已用完"); + } return prefix + String.format("%05d", sequence); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index 5a40decd..837d12aa 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -25,6 +25,7 @@ import org.springblade.system.cache.UserCache; import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.entity.DictBiz; import org.springblade.system.pojo.entity.CargoType; +import org.springblade.system.pojo.entity.MeasurementUnit; import org.springblade.transport.excel.WaybillImportBatchExcel; import org.springblade.transport.mapper.WaybillImportBatchMapper; import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; @@ -385,9 +386,17 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl idList = Func.toLongList(ids); if (Func.isEmpty(idList)) throw new ServiceException("请选择需要删除的运单批次"); + List batches = listByIds(idList).stream() + .filter(batch -> !Objects.equals(batch.getIsDeleted(), 1)) + .toList(); + // 与前端一致:仅草稿可删;选中含已完成/进行中时整批拒绝 + boolean hasNonDraft = batches.stream() + .anyMatch(batch -> !STATUS_DRAFT.equals(batch.getImportStatus())); + if (hasNonDraft) { + throw new ServiceException("已完成运单不可删除"); + } BusinessRemoveResultVO result = new BusinessRemoveResultVO(); - for (WaybillImportBatch batch : listByIds(idList)) { - if (Objects.equals(batch.getIsDeleted(), 1)) continue; + for (WaybillImportBatch batch : batches) { List waybills = waybillService.list(Wrappers.lambdaQuery() .eq(Waybill::getImportBatchId, batch.getId()).eq(Waybill::getIsDeleted, 0)); if (Func.isNotEmpty(waybills)) { @@ -456,7 +465,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row) { + if (row != null && row.containsKey("vehicleNo")) { + return stringValue(row, "vehicleNo"); + } + return stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号", "车号"); + } + private Long longValue(Map row, String field, String fieldName) { String value = stringValue(row, field); if (Func.isEmpty(value)) return null; @@ -783,12 +800,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List errors) { - String vehicleNo = stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号"); + String vehicleNo = vehicleNoFromRow(row); String transportType = stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式"); if (Func.isEmpty(transportType)) { @@ -911,13 +928,14 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List quantityUnitOptions, List errors) { - String quantityUnit = stringValue(row, "quantityUnit", "数量单位"); + String quantityUnit = stringValue(row, "quantityUnit", "数量单位", "*数量单位"); if (Func.isEmpty(quantityUnit)) { return; } - - if (!quantityUnitOptions.contains(quantityUnit)) { - errors.add("数量单位必须为系统枚举值之一"); + String normalized = quantityUnit.trim(); + boolean matched = quantityUnitOptions.stream().anyMatch(option -> option.equalsIgnoreCase(normalized)); + if (!matched) { + errors.add("数量单位「" + normalized + "」未在计量单位主数据中匹配"); } } @@ -1045,12 +1063,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl loadQuantityUnitOptions() { - // 常见数量单位 - return List.of("吨", "千克", "立方米", "件", "箱", "台", "个", "升", "米", "平方米"); + R> response = sysClient.getMeasurementUnits(); + if (response == null || !response.isSuccess() || response.getData() == null) { + throw new ServiceException("计量单位基础数据读取失败,请稍后重试"); + } + Set options = new HashSet<>(); + for (MeasurementUnit unit : response.getData()) { + if (unit == null || (unit.getStatus() != null && !Objects.equals(unit.getStatus(), 1))) { + continue; + } + if (Func.isNotEmpty(unit.getUnitName())) { + options.add(unit.getUnitName().trim()); + } + if (Func.isNotEmpty(unit.getUnitCode())) { + options.add(unit.getUnitCode().trim()); + } + } + if (options.isEmpty()) { + throw new ServiceException("计量单位主数据为空,请先在基础数据中配置计量单位"); + } + return new ArrayList<>(options); } /** @@ -1190,7 +1227,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl @Override @Transactional(rollbackFor = Exception.class) public boolean complete(Long id) { - return doComplete(loadEditable(id, true)); + // 暂时取消组织校验:历史运单 deptId 可能为 -1 / 与当前组织不一致,完成时不再拦截 + return doComplete(loadEditable(id, false)); } @Override diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java index f091b00f..f2d12f48 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java @@ -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 财务应付单保存 ==================== /** @@ -233,7 +260,7 @@ public class KingdeeApBillSyncSupport { if (Func.isBlank(billtypeNumber)) { throw new ServiceException("推送金蝶财务应付单失败:单据类型[" + KingdeeBfiConstant.BFI_AP_BILL_BILLTYPE_NAME + "]未在映射字典中维护"); } - String orgNumber = bfiOrgNumber(contract); + String orgNumber = bfiOrgNumber(contract, context); // 币种取合同结算币种 String currency = bfiCurrency(contract); // 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),流程拆分后以同步当日作为应付单据日期 @@ -828,7 +855,7 @@ public class KingdeeApBillSyncSupport { } Map categoryNames = DictBizCache.getList(FEE_CATEGORY_DICT).stream() .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first)); - R> response = sysClient.getFeeItems(); + R> response = sysClient.getFeeItems(false); if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试"); } @@ -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()); } } @@ -925,24 +952,75 @@ public class KingdeeApBillSyncSupport { } /** - * 合同所属组织编码 + * 合同金蝶组织编码:合同关联项目 → 项目业务部门沿部门树向上反查唯一平台公司 → 平台公司金蝶组织编码 * * @param contract 合同实体 + * @param context 金蝶同步上下文(缓存载体) * @return 组织编码 */ - public String bfiOrgNumber(ContractManage contract) { - Dept dept = contract.getOrganizationId() == null ? null : SysCache.getDept(contract.getOrganizationId()); - // dept为空,直接报错 - if (dept == null) { - throw new ServiceException("推送金蝶失败,合同所属组织不存在,请检查合同所属组织配置"); + public String bfiOrgNumber(ContractManage contract, KingdeeSyncContext context) { + if (contract.getProjectId() == null) { + throw new ServiceException("推送金蝶失败,合同[" + Func.toStr(contract.getContractNo()) + "]未关联项目,无法确定金蝶组织编码"); } - // kingdeeDeptCode为空,直接报错 - if (Func.isBlank(dept.getKingdeeDeptCode())) { - throw new ServiceException("推送金蝶失败,合同所属组织编码不能为空,请检查组织[" + dept.getDeptName() + "]是否维护金蝶组织编码"); + return this.kingdeeOrgNumber(contract.getProjectId(), context); + } + + /** + * 金蝶组织编码:项目业务部门沿部门树向上反查唯一平台公司(是否平台公司=是),取该平台公司的金蝶组织编码; + * 项目未关联/未维护业务部门/业务部门未归属平台公司/平台公司未维护金蝶组织编码时拦截 + * + * @param projectId 项目id + * @param context 金蝶同步上下文(缓存载体) + * @return 组织编码 + */ + public String kingdeeOrgNumber(Long projectId, KingdeeSyncContext context) { + ProjectApply project = this.requireOrgProject(projectId, context); + // 沿部门树向上反查平台公司,上限 20 级防脏数据成环死循环 + Dept dept = SysCache.getDept(project.getBusinessDeptId()); + for (int depth = 0; dept != null && depth < 20; depth++) { + if (Objects.equals(dept.getIsPlatformCompany(), 1)) { + if (Func.isBlank(dept.getKingdeeDeptCode())) { + throw new ServiceException("推送金蝶失败,平台公司[" + dept.getDeptName() + "]未维护金蝶组织编码,请检查部门数据"); + } + return dept.getKingdeeDeptCode(); + } + dept = dept.getParentId() == null ? null : SysCache.getDept(dept.getParentId()); + } + 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) * @@ -955,7 +1033,7 @@ public class KingdeeApBillSyncSupport { return "RMB".equalsIgnoreCase(currency) ? "CNY" : currency; } - /** 财务应付单往来类型:合同类别为“客户合同”时为 bd_customer(客户),否则 bd_supplier(供应商) */ + /** 财务应付单/付款申请/应收单/收票单统一往来类型:固定 bd_customer(按合同类别区分供应商的逻辑已注释停用,恢复时同步影响付款申请) */ public String bfiAsstactType(ContractManage contract) { return KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE; /*return "客户合同".equals(contract.getContractCategory()) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java index 34bf1de3..64a3a99a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java @@ -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 财务应收单保存 ==================== /** @@ -170,7 +197,7 @@ public class KingdeeArBillSyncSupport { if (Func.isBlank(billtypeNumber)) { throw new ServiceException("推送金蝶财务应收单失败:单据类型[" + KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME + "]未在映射字典中维护"); } - String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract); + String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract, context); // 币种取合同结算币种 String currency = kingdeeApBillSyncSupport.bfiCurrency(contract); // 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),以同步当日作为应收单据日期 @@ -193,8 +220,8 @@ public class KingdeeArBillSyncSupport { param.setRecorgNumber(orgNumber); // 【必填】单据日期 param.setBizdate(billDateText); - // 【必填】往来类型:应收固定客户 - param.setAsstacttype(KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE); + // 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径) + param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract)); // 【必填】往来户.编码:结算单付款方(客户) param.setAsstactNumber(customerCode); // 【必填】付款方式(CASH:现销, CREDIT:赊销),现销由金蝶自动推收款 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java index cd80fd69..48a74c14 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java @@ -183,10 +183,10 @@ public class KingdeeReceivedInvoiceSyncSupport { param.setInvoicetypef7Number(mirror.getInvoiceTypeCode()); // 【必填】付款方式:(与应付单一致,不允许金蝶自动推付款) param.setPaymenttype(BfiConstant.PAY_MODE_CASH); - // 【必填】往来类型:进项收票固定供应商 - param.setAsstacttype(BfiConstant.ASSTACT_TYPE_SUPPLIER); - // 【必填】结算组织.编码:合同所属组织(与应付单同源) - param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract)); + // 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径) + param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract)); + // 【必填】结算组织.编码:合同关联项目业务部门反查平台公司(与应付单同源) + param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract, context)); // 【必填】结算币别:合同结算币种 param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract)); // 【必填】往来户.编码:合同乙方(开票供应商)客商编码 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/SettlementRuleAutoGenerateTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/SettlementRuleAutoGenerateTask.java new file mode 100644 index 00000000..80cc5d8b --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/SettlementRuleAutoGenerateTask.java @@ -0,0 +1,59 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.service.ISettlementRuleAutoGenerateService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 合同结算规则定时生成结算单。 + *

+ * 每天零点执行:预结算或正式结算配置开启自动生成时,按账单开始日期和结算周期归集已关闭账期的运单明细。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SettlementRuleAutoGenerateTask { + + private final ISettlementRuleAutoGenerateService settlementRuleAutoGenerateService; + + /** 每天 00:00 生成到期的预结算单、正式结算单 */ + @Scheduled(cron = "${blade.settlement.auto-generate-cron:0 0 0 * * ?}") + public void generateDueSettlements() { + try { + String summary = settlementRuleAutoGenerateService.generateDueSettlements(); + log.info("定时自动生成结算单:{}", summary); + } catch (Exception exception) { + log.error("定时自动生成结算单异常", exception); + } + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/FormalSettlementWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/FormalSettlementWrapper.java index 4f4a331a..68142f7d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/FormalSettlementWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/FormalSettlementWrapper.java @@ -45,6 +45,7 @@ public class FormalSettlementWrapper extends BaseEntityWrapper "已作废"; default -> entity.getApprovalStatus(); }); + vo.setAdjustStatusName("adjusted".equals(entity.getAdjustStatus()) ? "已调整" : null); return vo; } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ReceiptFlowWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ReceiptFlowWrapper.java index b4212704..e0e59387 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ReceiptFlowWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ReceiptFlowWrapper.java @@ -70,10 +70,15 @@ public class ReceiptFlowWrapper extends BaseEntityWrapper "处理失败"; default -> "未推送"; }); - // 剩余可认领金额 = 公告未认领金额 − 累计认领金额(认领上限为公告口径) + // 剩余可认领金额 = 可认领上限 − 累计认领金额(上限取公告未认领与收款金额较小值) BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount()); + BigDecimal receiptAmount = money(entity.getReceiptAmount()); + BigDecimal claimCeiling = unclaimedAmount; + if (receiptAmount.compareTo(BigDecimal.ZERO) > 0 && unclaimedAmount.compareTo(BigDecimal.ZERO) > 0) { + claimCeiling = unclaimedAmount.min(receiptAmount); + } BigDecimal claimedAmount = money(entity.getClaimedAmount()); - vo.setRemainingAmount(unclaimedAmount.subtract(claimedAmount).max(BigDecimal.ZERO)); + vo.setRemainingAmount(claimCeiling.subtract(claimedAmount).max(BigDecimal.ZERO)); vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser())); return vo; diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java index 341cdca8..11424966 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java @@ -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 + ")"; + } } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java index e8fe9d91..fbbd3fb8 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java @@ -94,6 +94,7 @@ public class BfiBlacklistVO implements Serializable { /** * 失信类别[A:禁止交易类, B:风险警示类] */ + @JsonProperty("awrp_combofield") private String awrpCombofield; /** * 失信类别标题 diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java index 0cc4b18c..de5f8b77 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java @@ -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 响应数据类型 + * @return 定位后缀,形如 (金蝶单据:xxx) + */ + private String billSaveFailDetail(BfiResultVO 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 * diff --git a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md b/doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md similarity index 95% rename from doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md rename to doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md index 8f2980b7..13b52fd4 100644 --- a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md +++ b/doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md @@ -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` 允许重推(防重键保证金蝶侧幂等) | diff --git a/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md index c0df77c1..a7e9df35 100644 --- a/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md +++ b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md @@ -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`,同上处理 | diff --git a/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md index 46d7f4d1..4f80f684 100644 --- a/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md +++ b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md @@ -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 --- diff --git a/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md index 130d952c..c72838ed 100644 --- a/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md +++ b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md @@ -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` diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md index d5097a95..270be3fa 100644 --- a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).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 默认值、错峰、终态不轮询、防呆与自愈行为)。系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。 diff --git a/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md b/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md new file mode 100644 index 00000000..21f883f3 --- /dev/null +++ b/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md @@ -0,0 +1,409 @@ +# 金蝶(星瀚)BFI 推送入参取值映射表:页面字段 → 数据库表字段 / 配置对照 + +> 整理日期:2026-10-15 +> 代码基线:`tms-api` 当前 master(分支 `feature/payment-apply-bfi-sync`,提交 `259407c3` 之后)。 +> 用途:给联调/实施/排查用——每个送金蝶的入参,**页面上是哪个字段、落库在哪张表哪个字段、或来自哪个配置/常量**。逐字段核对代码(文件+行号见各节备注)。 +> 配套文档:《2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md》。 + +--- + +## 目录 + +- [0. 公共取数与通用规则](#0-公共取数与通用规则) +- [1. A100-1 获取令牌](#1-a100-1-获取令牌) +- [2. A902-2 客户保存](#2-a902-2-客户保存) +- [3. A801-5 黑名单查询](#3-a801-5-黑名单查询) +- [4. A102-2 财务应付单保存](#4-a102-2-财务应付单保存) +- [5. A302-2 财务应收单保存(与应付差异)](#5-a302-2-财务应收单保存与应付差异) +- [6. A103-1 付款申请下推](#6-a103-1-付款申请下推) +- [7. A103-3 预付款申请保存](#7-a103-3-预付款申请保存) +- [8. A303-1 开票申请单保存](#8-a303-1-开票申请单保存) +- [9. A303-2 收票单保存 + A302-6 指定应付](#9-a303-2-收票单保存--a302-6-指定应付) +- [10. A301-3 收款认领结果推送](#10-a301-3-收款认领结果推送) +- [11. lockBusiData 单据锁定](#11-lockbusidata-单据锁定) +- [12. A902 附件上传](#12-a902-附件上传) +- [13. 查询类接口入参简表](#13-查询类接口入参简表) +- [14. 与总纲记载不一致的核对结论](#14-与总纲记载不一致的核对结论) + +--- + +## 0. 公共取数与通用规则 + +### 0.1 配置前缀(两套并存,勿混) + +| 前缀 | 归属 | 内容 | +|---|---|---| +| `third-party.bfi.*` | bfi-api 客户端(令牌/网关) | baseUrl、authorization、clientId、clientSecret、username、accountId、language | +| `thirdparty.bfi.*` | blade-transport 业务 | srcSys、各业务开关/cron、payApply.*、invoiceApply.*、receivedInvoice.*、material.*、claimAnnounce.*、blacklist.*、billLock.*(宽松绑定兼容 camelCase) | + +### 0.2 通用取数 + +| 项 | 规则 | 代码 | +|---|---|---| +| awrp_src_sys / systemsource / sourceSystem | Nacos `thirdparty.bfi.srcSys`,默认常量 `WLTMS` | BfiProperties(srcSys) | +| 组织 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 | +| 防重键 awrp_src_code | `ExternalSrcCodeSupport.buildSrcCode`:下划线拼接、去空白、去重保序 | ExternalSrcCodeSupport L54-63 | + +### 0.3 分录拆分规则(A102-2/A302-2/A303-2 共用 `resolveEntryGroups`) + +- 拆分维度:**货物 → 费用大类(装卸/运输)→ 税率** 三级分组,各组成一条分录;手动合计费用项(manual_flag=1)每行一条。 +- 物料编码:装卸大类/手动非运费杂费 → `thirdparty.bfi.material.loading`(默认 XDWL11002004);项目业务模式或业务类型含「国际」→ `.international`(Item-00001081);国内按运单运输方式 → `.domestic.公路运输`=XDWL11010001 / `.铁路运输`=XDWL11010002 / `.水路运输`=XDWL11010003 / `.航空运输`=Item-00001082;运输方式未配置直接拦截。 +- 税率:货物费用行按合同计费方案规则分组(`blade_contract_manage.billing_plan_json` 的 `rules[].feeType/feeItem/taxRate`);税率未配置并入无税率组。 +- 数量:运输类=结算明细运输量(≤0 兜底 1,同税率拆多行每行都取运输量);装卸/合计费用项行=1;单价=金额÷数量反算(2 位 HALF_UP)。 +- 0 元费用项(signum==0)与 0 元货物行整行跳过。 +- 计量单位**当前不送**(DTO 有字段无赋值)。 + +--- + +## 1. A100-1 获取令牌 + +| 入参 | 含义 | 来源:配置键 / 生成方式 | +|---|---|---| +| client_id | 应用编码 | `third-party.bfi.clientId` | +| client_secret | 认证密钥 | `third-party.bfi.clientSecret` | +| username | 访问用户名 | `third-party.bfi.username` | +| accountId | 数据中心 ID | `third-party.bfi.accountId` | +| language | 语言 | `third-party.bfi.language`(默认 `zh_CN`) | +| nonce | 随机数 | 每次生成 `UUID` | +| timestamp | 时间戳(±5 分钟校验) | 每次生成 `yyyy-MM-dd HH:mm:ss` | + +> 令牌缓存 Redis `blade:third:bfi:token`,TTL=expires_in−120s;`setIfAbsent` 写入。代码:BfiServiceImpl L136-156、BfiTokenDTO L44-65。 + +--- + +## 2. A902-2 客户保存 + +调用链:`CustomerArchiveServiceImpl.syncKingdee → pushCustomerToKingdee → CustomerBfiConverter.buildSaveDTO` + +| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 常量 | +|---|---|---|---| +| number | 客户编码(不传由星瀚生成) | 客商编号(提交时生成 KS+6 位) | blade_customer_archive.customer_code | +| name【必】 | 名称 | 客商名称 | blade_customer_archive.full_name | +| societycreditcode【必】 | 统一社会信用代码(金蝶候选键) | 统一社会信用代码 | blade_customer_archive.unified_credit_code | +| tx_register_no | 纳税人识别号 | 同上(同一字段) | blade_customer_archive.unified_credit_code | +| artificialperson | 法人代表 | 企业法人 | blade_customer_archive.legal_person | +| type【必】 | 伙伴类型(1~17) | 客商性质字典 | blade_customer_archive.customer_nature + 字典 `nature_of_client` dictKey(文本值反查 dictKey,无效抛异常) | +| bizpartner_address | 详细地址 | 注册地址 | blade_customer_archive.registered_address | +| bizpartner_phone | 联系电话 | 联系电话 | blade_customer_archive.contact_phone | +| businessterm | 营业期限 | 营业期限类型=长期→「长期」;否则「至 yyyy-MM-dd」 | blade_customer_archive.business_term_type / business_end_date | +| businessscope | 经营范围 | 经营范围多选 → 字典 `scope_of_business` 文本「、」拼接 | blade_customer_archive.business_scope | +| bizfunction | 业务职能 | 固定全选 | 常量 `"1,2,3,4"` | +| fcusbeizhu | 备注 | 备注(空不传) | blade_customer_archive.remark | +| awrp_datasourcesys | 数据来源 | 配置 | `thirdparty.bfi.srcSys` | +| awrp_cusfortrade【必】 | 是否贸易客商 | 固定 | 常量 `"A"` | +| entry_groupstandard[].standardid_number【必】 | 分类标准 | 固定 | 常量 `"JBFLBZ"` | +| entry_groupstandard[].groupid_number【必】 | 客户分组 | 客户类型:internal→1101,其余→1102 | blade_customer_archive.customer_kind + 常量 1101/1102 | +| entry_bank[].bankaccount | 银行账号(金蝶候选键) | 收款账号 | blade_customer_receipt_account.bank_account | +| entry_bank[].accountname | 账户名称 | **传开户行名称** | blade_customer_receipt_account.bank_name | +| entry_bank[].bank_number | 开户银行.编码(联行号) | 联行号 | blade_customer_receipt_account.cnaps_code | +| entry_bank[].currency_number【必】 | 币种 | 固定 | 常量 `"CNY"` | +| entry_bank[].isdefault_bank | 默认银行 | 固定 | `true` | +| entry_linkman[].contactperson | 联系人 | 客商负责人(空则整组分录不传) | blade_customer_archive.principal | +| entry_linkman[].phone | 电话 | 联系电话 | blade_customer_archive.contact_phone | +| entry_linkman[].isdefault_linkman | 默认联系人 | 固定 | `true` | + +不传字段:status、linkman、establishdate、postal_code、createorg_number(已注释)等。 +> 代码:CustomerBfiConverter L67-280、常量 CustomerBfiConstants / BfiConstant L288-313。 + +--- + +## 3. A801-5 黑名单查询 + +| 入参 | 含义 | 来源 | +|---|---|---| +| enable | 使用状态 | 固定 `"1"`(可用) | +| number | 企业税号数组 | 客商发票信息所有纳税人识别号,`trim().toUpperCase()` 去重 → blade_customer_invoice_info.tax_no | +| pageNo / pageSize | 分页 | 自动翻页(50 页保护)/ 固定 `2000` | +| name | 企业名称数组 | 不传 | + +> 命中判定按应答 awrpCombofield:A 硬拦截;B 默认放行告警(`thirdparty.bfi.blacklist.blockRiskWarning=true` 时拦截);查询异常上抛阻断提审。代码:CustomerArchiveServiceImpl L1043-1092、BfiConstant L90。 + +--- + +## 4. A102-2 财务应付单保存 + +### 4.1 单据头 + +| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| awrp_src_code【必·候选键】 | 来源单据号(防重键) | 结算单号+结算明细单据号 | blade_formal_settlement.formal_settlement_no + blade_formal_settlement_detail.document_no(document_no 空兜底明细行 id)下划线拼接 | +| awrp_src_sys【必】 | 外部系统编号 | 配置 | `thirdparty.bfi.srcSys`(WLTMS) | +| awrp_src_type【必】 | 外部单据类型 | — | 常量 `TMS财务应付单` | +| billtypeid_number【必】 | 单据类型.编码 | — | 常量字典「服务采购应付」→ `ApFin_service_BT_S` | +| 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 | +| purmode【必】 | 付款方式 | — | 常量 `CASH`(现购) | +| currency_number【必】 | 结算币别 | 合同结算币种 | blade_contract_manage.settlement_currency(RMB→CNY) | +| payproperty_number【必】 | 款项性质.编码 | — | 常量 `2001`(材料采购) | +| isincludetax / ispricetotal | 含税价/总价录入 | — | `true` / `true` | +| isintax | 按应付金额分配 | — | `false` | +| exratedate / exchangerate | 汇率日期/汇率 | 结算单页面「汇率日期/结算汇率」,未维护不送 | blade_formal_settlement.exchange_rate_date / .exchange_rate | +| settlementtype_number | 结算方式 | **不送**(仅 A103-1 分录需要) | — | +| taxrateid_number / purorg_number / exratetable_number / planentity 等 | 可选 | **均不送**(planentity 由金蝶按分录自动汇总付款计划) | — | + +### 4.2 明细分录 detailentry + +| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| awrp_entry_code【必·候选键】 | 分录标识 | 拆分行=`明细单据号-货物序号-大类-税率`;合计费用项行=`结算单号-费用类型-费用项` | blade_formal_settlement_detail.document_no / blade_formal_settlement_summary_fee.fee_type+fee_item | +| material_number【必】 | 物料编码.编码 | 按拆分规则映射(见 0.3) | 配置 `thirdparty.bfi.material.loading / international / domestic.公路运输 / .铁路运输 / .水路运输 / .航空运输`;判定字段 blade_project_apply.business_mode / business_type、blade_formal_settlement_detail.transport_type | +| awrp_transport | 运输货物 | 货物名称(合计费用项行不传) | blade_formal_settlement_detail.cargo_name(一车多货取 blade_formal_settlement_detail_fee.cargo_name) | +| awrp_lpn | 车号 | 车号(合计费用项行不传) | blade_formal_settlement_detail.vehicle_no | +| quantity【必】 | 数量 | 运输量(≤0 兜底 1;装卸/合计行=1) | blade_formal_settlement_detail.transport_quantity(一车多货取 detail_fee.transport_quantity) | +| price | 单价 | 金额÷数量反算 | 派生值 | +| e_pricetaxtotal【必】 | 应付金额(含税) | 结算金额 | blade_formal_settlement_detail.settlement_amount_tax / blade_formal_settlement_summary_fee.settlement_amount | +| e_amount | 金额(不含税) | 不含税结算金额(仅运输类携带;合计行不传) | blade_formal_settlement_detail.settlement_amount_no_tax | +| project_number【必】 | 项目.编码 | 联调临时值 | 常量 `"999"` | +| taxrateid_number | 税率.编码 | 不送(税率只作拆行依据) | blade_contract_manage.billing_plan_json | + +--- + +## 5. A302-2 财务应收单保存(与应付差异) + +分录与应付**同源同口径**(复用 resolveEntryGroups,分录字段名前缀 e_:e_material_number/e_quantity/e_unitprice/e_recamount(含税)/e_amount(不含税))。头字段差异: + +| 金蝶入参 | 应收取值 | 来源 | +|---|---|---| +| awrp_src_type | `TMS财务应收单` | 常量 | +| billtype_number | 「服务销售应收」→ `arfin_sersal_BT_S` | 常量映射 | +| recorg_number | 收款组织=结算组织 | 同 org_number | +| asstact_number | **结算单付款方**(非合同乙方) | blade_formal_settlement.payer_name → blade_customer_archive.customer_code | +| paymode | 现销 `CASH` | 常量 | +| payproperty_number | `1001`(一般销售) | 常量 | +| bizdate | 同步当日 | LocalDate.now() | +| settlementtype_number / duedate 等 | 不送(TODO 待金蝶确认) | — | + +--- + +## 6. A103-1 付款申请下推 + +| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| awrp_src_code【必·候选键】 | 来源单据号 | 付款申请单号 | blade_payment_application.payment_no | +| awrp_src_sys【必】 | 外部系统 | 配置 | `thirdparty.bfi.srcSys` | +| applydate【必】 | 申请日期 | 付款申请页-申请日期(空取当天) | blade_payment_application.apply_date | +| entry[].e_paymenttype_number【必】 | 付款类型 | 正式付款 | 常量 `201` | +| entry[].e_settlementtype_number | 结算方式 | 付款方式含「汇票」→`012-02`(电子银行承兑汇票),其余→`02`(银行转账) | 字典 `pay_method` + blade_payment_application.payment_method | +| entry[].awrp_finapbill_entryid【必】 | 勾稽应付单分录标识 | 主结算单应付单防重键(本地组装) | blade_formal_settlement.formal_settlement_no + blade_formal_settlement_detail.document_no(主结算单=blade_payment_application.settlement_id,空取关联明细首个结算单) | +| entry[].awrp_entry_code【必】 | 行 id | 付款申请号-1 | payment_no + "-1" | +| entry[].e_assacct【必】 | 往来账户(收款账号) | 收款账号 | blade_payment_application.bank_account | +| entry[].e_bebank_number【必】 | 往来银行.编码(联行号) | 收款账户联行号(缺失拦截;兜底按合同乙方客商+收款账号匹配) | blade_customer_receipt_account.cnaps_code(按 blade_payment_application.receipt_account_id 直查) | +| entry[].e_applyamount【必】 | 申请金额 | 申请付款金额(2 位 HALF_UP) | blade_payment_application.applied_amount | +| entry[].project_number【必】 | 项目.编码 | 联调临时值 | 常量 `"999"` | +| entry[].awrp_src_type【必】 | 外部单据类型 | 配置 | `thirdparty.bfi.payApply.srcType`(默认 `0KzXs`) | +| exratetable_number / awrp_textfield1 | 汇率表/付款渠道 | **实际不送**(仅 A103-3 送;配置 exratetable=01 在此接口不生效) | — | + +--- + +## 7. A103-3 预付款申请保存 + +| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| billtype_number【必】 | 单据类型 | 配置 | `thirdparty.bfi.payApply.billtype`(默认 `ap_payapply_oth_BT_S`) | +| 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`) | +| exratedate【必】 | 汇率日期 | 关联结算单维护值,兜底申请日期 | blade_formal_settlement.exchange_rate_date → blade_pre_settlement.exchange_rate_date → blade_payment_application.apply_date | +| awrp_textfield1 | 付款渠道 | 付款方式字典标签(**此接口实际送**) | 字典 `pay_method` 翻译 blade_payment_application.payment_method | +| entry[].e_paymenttype_number【必】 | 付款类型 | 结算付款→`201`;项目垫资/进度垫资→`202` | 常量 BFI_PAYMENT_TYPE_FORMAL / BFI_PAYMENT_TYPE_PREPAY | +| entry[].e_asstacttype / e_asstactnumber【必】 | 往来类型/户 | 固定客户 / 合同乙方→客商编码 | `bd_customer`;blade_contract_manage.party_b → customer_code | +| entry[].e_assacct / e_bebank_number【必】 | 账号/联行号 | 同 A103-1 | blade_payment_application.bank_account / blade_customer_receipt_account.cnaps_code | +| entry[].e_applyamount【必】 | 申请金额 | 申请付款金额 | blade_payment_application.applied_amount | +| entry[].project_number【必】 | 项目.编码 | **实际固定 999**(取项目档案真实编码的逻辑已注释停用) | 常量 `"999"`(备用:blade_project_apply.project_code) | +| entry[].e_remark | 备注 | 付款申请备注(空不传) | blade_payment_application.remark | +| entry[].awrp_entry_code / awrp_src_type【必】 | 行 id / 单据类型 | payment_no-1 / 配置 | `thirdparty.bfi.payApply.srcType` | + +--- + +## 8. A303-1 开票申请单保存(每张发票张次一单) + +### 8.1 主表 + +| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| billno【必·候选键】 | 单据编号 | 开票申请单号(多张加张次序号) | blade_invoice_application.application_no(多张 + "-" + blade_invoice_application_sheet.sheet_no) | +| 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 | +| buyeremail【必】 | 推送邮箱 | 部门邮箱(空拦截) | blade_invoice_application.department_emails | +| buyertaxno / buyername【必】 | 购方税号/名称 | 受票方快照 | blade_invoice_application.taxpayer_no / receiver_name | +| buyeraddr / buyerbank | 购方地址电话/开户行账号 | 受票方快照 | blade_invoice_application.registered_address+contact_phone / bank_name+bank_account | +| salername【必】 | 销方名称 | 开票单位(空拦截) | blade_invoice_application.issuer_name | +| salertaxno【必】 | 销方税号 | 开票单位→客商档案 | blade_customer_archive.unified_credit_code(按 issuer_name 匹配) | +| saleraddr / salerbank | 销方地址电话/开户行账号(数电专票强制) | 客商档案(启用开票信息优先) | blade_customer_archive.registered_address+contact_phone;blade_customer_invoice_info.bank_name+bank_account | +| totalamount【必】 | 申请价税合计 | 本张开票金额 | blade_invoice_application_sheet.invoice_amount | +| hsbz | 是否含税 | 固定不含税 | 常量 `"0"` | +| specialtype | 特殊票种 | 本张商品行**全部**为货物运输类→`E04`,否则→`00`(名称/分类含「运费/货物运输」关键字) | 常量 BFI_INVOICE_SPECIAL_TYPE_FREIGHT / NONE + BFI_INVOICE_FREIGHT_KEYWORDS | +| freights[] | 货物运输单据体(E04 报送) | 结算明细快照,见 8.2 | blade_invoice_application_detail | +| customnameid_number【必】 | 客户.编码 | 受票方客户 | blade_invoice_application.receiver_customer_id → blade_customer_archive.customer_code | +| awrp_project_number【必】 | 项目.编码 | 联调临时值 | 常量 `"999"` | +| awrp_src_bt | 上游单据类型 | 固定 | `ar_finarbill` | +| awrp_src_con_no | 上游单据号 | 首张关联结算单金蝶应收防重键 | blade_formal_settlement.formal_settlement_no + detail.document_no(经 blade_invoice_application_settlement 关联) | +| systemsource | 来源系统 | 配置 | `thirdparty.bfi.srcSys` | +| showbuyeraddresstel / showbuyerbank | 票面展示开关 | 固定 | `true` | + +### 8.2 freights[](按「车牌×起运×到达×货物×种类」去重归并,全空行剔除) + +| 入参 | 来源:blade_invoice_application_detail 字段 | +|---|---| +| transporttype 运输工具种类 | transport_type(字典 `transport_type` 翻译,不在合法枚举兜底「其它运输工具」) | +| licenseplate 牌号 | vehicle_no | +| transportgoods 货物名称 | cargo_name | +| startplace 起运地 | departure_address | +| endplace 到达地 | arrival_address | + +### 8.3 sim_original_bill_item[] 分录(blade_invoice_application_line 商品行,line_no 升序) + +| 入参 | 来源:blade_invoice_application_line 字段 | +|---|---| +| awrp_entry_code【必】 | id(雪花 id) | +| rowtype / benchmark【必】 | 常量 `"2"`(商品行)/ `"0"`(数量基准) | +| goodsname【必】 | goods_name(货物或服务简称) | +| goodssimplename【必】 | goods_category(商品和服务分类) | +| taxratecodeid_number / goodscode【必】 | tax_classification_code(税收分类编码,空拦截) | +| num / unit | quantity(0 兜底 1)/ unit(空兜底「个」) | +| unitprice / amount / tax | unit_price_no_tax / amount_no_tax / tax_amount | +| taxrate | tax_rate(百分数÷100 转小数,0 送 "0.00") | +| remark | remark | +| awrp_src_type【必】 | 配置 `thirdparty.bfi.invoiceApply.srcType`(默认 `0KzXs`) | + +(specification 规格型号、含税金额等本期不传。) + +--- + +## 9. A303-2 收票单保存 + A302-6 指定应付 + +### A303-2 收票单批量保存 + +| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| awrp_src_code【必·候选键】 | 来源单据号 | 收票登记 id(一票一登记) | blade_invoice_receipt.id | +| awrp_src_sys / awrp_src_type【必】 | 系统/单据类型 | 配置 / 常量 | `thirdparty.bfi.srcSys`;`TMS收票登记` | +| invoiceno | 发票号码 | 收票登记发票号 | blade_invoice_receipt.invoice_no | +| receivedate【必】 | 收票日期 | 推送当日 | LocalDate.now() | +| issuedate【必】 | 开票日期 | 票池镜像票面 | blade_kingdee_invoice_pool.invoice_date | +| biztype【必】 | 业务类型 | 固定采购 | `PUR` | +| invoicetypef7_number【必】 | 发票类型.编码 | 票易通原始编码(缺失拦截并按票号回源 A801-1 补填) | blade_kingdee_invoice_pool.invoice_type_code | +| paymenttype【必】 | 付款方式 | 固定现购 | `CASH` | +| asstacttype【必】 | 往来类型 | 统一固定客户(与应付单/付款申请同口径) | `bd_customer` | +| 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 | +| buyername【必】 | 购方名称 | 镜像受票单位 | blade_kingdee_invoice_pool.receiver_name | +| sellertel / sellerbank / selleracct | 销方电话/开户行/账号 | 镜像 | blade_kingdee_invoice_pool.phone / bank_name / bank_account | +| isincludetax | 含税录入 | 固定 | `true` | +| entry[] | 分录 | 与 A102-2 同源同口径(复用 resolveEntryGroups) | 同 4.2 | + +### A302-6 收票指定应付 + +| 入参 | 来源 | +|---|---| +| op | 常量 `"assign"` | +| ap_invoice[0].awrp_src_code / awrp_src_sys | blade_invoice_receipt.id / `thirdparty.bfi.srcSys` | +| ap_finapbill[0].awrp_src_code / awrp_src_sys | 应付防重键(结算单号_明细单据号)/ 同上 | + +--- + +## 10. A301-3 收款认领结果推送 + +| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| billid【必】 | 认领公告 id | 流水同步落库(缺失拦截「请先同步金蝶收款流水」) | blade_kingdee_receipt_flow.bill_id | +| awrp_src_sys【必】 | 来源系统 | 配置 | `thirdparty.bfi.srcSys` | +| awrp_src_code【必】 | 来源单据编码 | 该流水全部分摊结算单号去重下划线拼接(推送时生成落库,分摊不变重试复用) | blade_receipt_claim_settlement.formal_settlement_no(status=1,结算单 id 升序)→ 回写 blade_kingdee_receipt_flow.awrp_src_code | +| recpaytype_number【必】 | 收款类型 | **常量当前值 `"999"`**(注释「示例 101 待金蝶确认」) | KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE | +| paymenttype【必】 | 付款人类型 | 固定 | `bd_customer` | +| recviewpayer / recpayer【必】 | 付款人名称/id | 流水付款方 | blade_kingdee_receipt_flow.payer_name / payer_id | +| entryentity[0].e_receivableamt【必】 | 应收金额(单条分录) | 全部有效认领分摊金额合计 | SUM(blade_receipt_claim_settlement.allocated_receipt_amount) | +| entryentity[0].seq / e_corebillno【必】 | 行序/核心单据编号 | 第一张分摊结算单 id / 其金蝶星瀚内码(缺失拦截「未同步金蝶」) | blade_receipt_claim_settlement.formal_settlement_id / blade_formal_settlement.kingdee_bill_id | +| entryentity[0].e_corebilltype | 核心单据类型 | **注释不传** | — | + +--- + +## 11. lockBusiData 单据锁定 + +| 入参 | 含义 | 来源 | +|---|---|---| +| lockAction【必】 | 操作 | 常量 `LOCK` | +| sourceSystem【必】 | 源系统 | `thirdparty.bfi.srcSys`(默认 WLTMS) | +| targetSystem【必】 | 目标系统 | 常量 `XH` | +| operationType【必】 | 操作类型 | 常量 `SAVE` | +| sourceBillType【必】 | 源单据类型 | 见下表常量 | +| sourceBillNo【必】 | 本地单据号 | 见下表 | +| targetBillType【必】 | 金蝶单据类型 | 见下表 | +| targetBillNo【必】 | 金蝶单号(逐张) | 见下表 | +| operator | 操作人 | 当前用户名(定时/补偿兜底 `system`;补偿取 blade_kingdee_bill_lock.lock_operator) | +| time | 操作时间 | 当前时间 `yyyy-MM-dd HH:mm:ss` | + +| sourceBillType | sourceBillNo | targetBillType | targetBillNo | +|---|---|---|---| +| `SETTLEMENT` | blade_formal_settlement.formal_settlement_no | receivable→`arfin_other_BT_S`;否则 `ApFin_other_BT_S` | 推送返回 billNo(多张逗号拆分逐张锁) | +| `PAY_APPLY` | blade_payment_application.payment_no | `ap_payapply_oth_BT_S` | blade_payment_application.kingdee_bill_no | +| `INVOICE_APPLY` | blade_invoice_application.application_no | `cdm_paybill_bank_elect_BT_S` | 推送返回 billNo(多张逗号拆分) | +| `RECEIVED_INVOICE` | — | `cdm_recbill_bank_BT_S` | 预留,**本期不锁** | + +> 锁记录表 blade_kingdee_bill_lock(lock_status: failed/locked/unlocked,try_count≤5);开关 `thirdparty.bfi.billLock.enabled`(默认 true)。代码:KingdeeBillLockServiceImpl L331-344、KingdeeLockConstant L24-87。 + +--- + +## 12. A902 附件上传 + +multipart 上传:`file`=文件流,`args`=JSON `{entityNumber, billPkId, controlKey}`。 + +| 入参 | 来源 | +|---|---| +| entityNumber(星止单据类型) | biz_type 映射:`pay_application`→ap_payapply;`formal_settlement_ap`→ap_finapbill;`formal_settlement_ar`→ar_finarbill;`invoice_application`→sim_original_bill;`invoice_receipt`→ap_invoice | +| billPkId(星瀚内码) | 推送落库的金蝶内码:blade_payment_application.kingdee_bill_id / blade_invoice_application.kingdee_bill_id / 结算单与收票推送返回内码(为空整单跳过待重推补传) | +| controlKey | 常量 `attachmentpanel` | +| 文件名 | 业务表 attachments_json 行内 originalName/name/fileName(空兜底 attachment;票池镜像兜底「发票文件」) | +| 文件流 | 业务表 attachments_json 的 url 下载;收票票池镜像按 path 经 A801-2 下载 | + +- 附件存储:各业务单据 `attachments_json` 列(非 blade_attachment 表);同步流水落 `blade_kingdee_attachment_sync`(biz_type/biz_id/attachment_key/kingdee_path/sync_status/fail_reason)。 +- 单文件软限制 20MB,超限拦截(重推时跳过该件继续其余)。 +- 调用时机:推送成功事务提交后联带上传,失败不污染主单状态;重推补传。 +> 代码:KingdeeAttachmentConstant L45-108、KingdeeAttachmentSyncSupport L125-264、BfiServiceImpl L613-638。 + +--- + +## 13. 查询类接口入参简表 + +| 接口 | 关键入参 | 取值来源 / 常量 / 配置 | +|---|---|---| +| A102-4 应付单查询 | awrp_src_code 多值(50/批)、awrp_src_sys、pageSize 200 | 应付防重键批次;`thirdparty.bfi.srcSys` | +| A302-4 应收单查询 | 同上(50/批、200) | 应收防重键批次 | +| A103-2 付款申请单查询 | awrp_src_code=paymentNo(50/批)、pageSize 200 | blade_payment_application.payment_no | +| A103-4 付款处理查询 | org_number(优先取 A103-2 返回 payorg_number,兜底合同组织)、业务日期今天回溯 1 个月、200/页 | A103-2 返回值 → blade_dept.kingdee_dept_code | +| A303-4 开票申请查询 | billno 多值(application_no、-张次序号)、pageSize=max(张数,10) | blade_invoice_application(.sheet) | +| A303-5 开票发票查询(openApi) | serialNo=金蝶单号逗号拆分(≤50/批)、sellerTaxpayerId=开票单位税号、请求体 Base64 | blade_invoice_application.kingdee_bill_no;blade_customer_archive.unified_credit_code | +| A801-3 收票单查询 | buyername(按镜像受票单位分组)、modifytimeS/E=回看 30 天、200/页;本地按 srcCode=登记 id 匹配 | blade_kingdee_invoice_pool.receiver_name;`thirdparty.bfi.receivedInvoice.queryWindowDays/resultWindowDays`(默认 30);blade_invoice_receipt.id | +| A801-1 票池查询 | invoiceStatus=`0`(正常票);invoiceType 单值(白名单逐轮);updateTime 窗口=incrementDays 增量、2.5 万触顶二分;500/页。**expenseStatus 不下推**(可用性本地过滤 amount_use_status=0 且 available_amount>0) | `thirdparty.bfi.invoiceReceipt.invoiceTypes / incrementDays`(30);本地表 blade_kingdee_invoice_pool | +| A801-2 附件下载 | path=文件地址(镜像优先、实时回源兜底;优先级 pdfurl→taxPdfUrl→downloadUrl,fileType=4 判 OFD) | blade_kingdee_invoice_pool.attachments_json | +| A301-1 收款单查询 | 业务日期今天回溯 30 天、200/页;勾稽 sourcebillnumber==awrp_src_code | blade_kingdee_receipt_flow.awrp_src_code | +| A301-2 认领公告查询 | claimusers=**Nacos 配置值**(登录人入参已废弃)、modifytimeS/E=回溯 months 个月、200/页;billstatus 字段存在但不传 | `thirdParty.bfi.claimAnnounce.claimusers`(必配)、`.months`(6) | + +--- + +## 14. 与总纲记载不一致的核对结论(已按代码核实) + +| # | 事项 | 代码事实 | +|---|---|---| +| 1 | A103-1 的 exratetable_number / awrp_textfield1 | **实际不送**(L1245/L1249 注释);配置 `payApply.exratetable=01` 只在 A103-3 生效 | +| 2 | A103-3 项目编码 | **实际固定 999**,「取项目档案 projectCode 真实编码」逻辑已注释停用(bfiProjectNumber L1508-1513)——总纲 12.2-#2「仅 A103-3 用真实编码」的说法作废,全部链路均为 999 | +| 3 | A301-3 recpaytype_number | 常量当前值 **"999"**(非旧文档写的 101;注释「示例 101 待金蝶确认」) | +| 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 为准。 diff --git a/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md b/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md new file mode 100644 index 00000000..1cd0cae7 --- /dev/null +++ b/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md @@ -0,0 +1,53 @@ +# 付款申请金蝶生成即回显预结算预付信息:synced 提前建档 + 作废联动清理 + +> 日期:2026-10-16 +> 关联文档: +> - `doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md`(A103-3 推送与 A103-2 消失解锁) +> - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md`(回写任务与 `applyPayApplyResult`) +> - `doc/docs/业务流程/2026-09-20-应付申请与付款申请同步处理全链路业务流程(A102-2下推-A102-4查询-A103-1生成-A103-2回写).md` + +## 1. 背景与问题 + +排查预结算单 YJ2026101500001(已完成预结算审批、付款申请 FKD2026101500001 审批、金蝶付款申请单生成 FKSQ-00000229):预结算单详情页"预付信息"卡片始终空白。 + +根因:`blade_pre_settlement_advance`(预付记录表,详情接口 `advances` 字段的数据源)此前**只在金蝶回写"付款完成"(`kingdeeStatus=paid`)时才写入**(`applyPayApplyResult` → `syncPreSettlementAdvance`)。付款申请推进到"已生成"(synced)阶段不落记录,页面预付信息必须等付款完成才可见;且基本信息区的预付申请/已付金额来自付款申请实时汇总(`fillPaymentApplicationAmounts`),金额有值、明细卡片空白,容易误判。 + +## 2. 改造内容(`PaymentApplicationServiceImpl`) + +| # | 变更点 | 变更前 | 变更后 | +|---|---|---|---| +| 1 | `syncPreSettlementAdvance` | 无参方法,写死 `billStatus="paid"`、已付=申请全额 | 参数化 `(entity, billStatus, paidAmount)`,建档与付款完成共用;按付款申请单号查重幂等 | +| 2 | `doSyncKingdee` synced 落库处 | 仅更新付款申请状态 | **生成成功即建档**:`approved` / 已付 0,同事务落库,预付信息提前回显 | +| 3 | `applyPayApplyResult` paid 分支 | 调原方法 | `paid` / 申请全额(语义不变),已建档时更新为已付款 | +| 4 | `applyPayApplyResult` 勾稽分支(新增) | failed 单据经 A103-2 勾稽确认 synced/paying 时不落预付记录 | 与推送成功同语义,`approved` / 已付 0 建档 | +| 5 | 新增 `voidPreSettlementAdvance` | 无 | 按付款申请单号将未付预付记录置 `voided`(`paid` 记录不联动,付款完成事实不回退),并刷新预结算汇总 | +| 6 | `voidBill`(付款申请作废) | 不清理预付记录 | 联动作废,避免残留记录阻断预结算作废(`PreSettlementServiceImpl#voidBill` 的"存在关联预付申请"拦截) | +| 7 | `unlockBill`(金蝶反审核解锁,含 A103-2 连续两轮消失判定) | 不清理预付记录 | 联动作废,重推生成新单后重新建档 | + +建档/作废后的联动刷新(`refreshPreSettlementAdvanceSummary` 预结算 advance_no/预付金额列、`formal_settlement_source` 预付金额、正式结算付款汇总)复用原有尾部逻辑,无需额外改动。转正式结算时来源表预付金额不再恒为 0。 + +## 3. 状态与联动时序 + +| 付款申请阶段 | 预付记录(blade_pre_settlement_advance) | 页面预付信息 | +|---|---|---| +| 审批通过、未推送 | 不存在 | 空白 | +| 金蝶付款申请单生成成功(synced) | `approved` / 已付 0(提前建档) | **回显**(状态"审批通过") | +| 付款中(paying)/ 勾稽确认已生成 | `approved`(补建或维持) | 回显 | +| 付款完成(paid 回写) | `paid` / 已付全额 | 回显(状态"已付款") | +| 付款申请作废 / 金蝶反审核解锁 | 未付记录置 `voided`(`paid` 不动) | 消失(汇总同步扣减) | + +前端零改动:`pre-settlement-editor.vue` 的 `advanceStatusName` 已含 `approved→审批通过` 映射。 + +## 4. 定时任务影响 + +- **付款结果回写任务(`syncKingdeePaymentResult`)**:零改动。轮询条件基于付款申请自身状态,不依赖预付记录表;paid 回写时命中已有记录更新,不产生重复。 +- **失败重推(`retryKingdeeFailed`)**:failed 阶段未建档,重推成功经 synced 建档一次,幂等查重兜底。 +- **A103-4 付款记录回填、金蝶单据锁补偿**:不涉及预付记录表,无影响。 + +## 5. 行为变化与存量数据 + +| 项 | 说明 | +|---|---| +| 预结算作废拦截提前 | "存在关联预付申请,请先作废预付申请"从付款完成后提前到金蝶生成即生效——作废预结算前需先作废付款申请(预期收紧) | +| 存量单据不自动补 | 改造上线前已 synced 的付款申请(如 FKD2026101500001)不会重推(状态门禁跳过 synced),预付记录要等 paid 回写补建;需立即回显的,手工补一条 `blade_pre_settlement_advance`(`advance_no=付款申请单号`、`bill_status='approved'`、已付 0) | +| 金额模糊匹配触发提前 | synced 建档时同额未付记录的兜底匹配与 paid 阶段同一逻辑,仅触发时点提前;如出现误绑定,可将模糊匹配收敛到 paid 分支 | diff --git a/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md b/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md new file mode 100644 index 00000000..e0bb492c --- /dev/null +++ b/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md @@ -0,0 +1,130 @@ +# 收款流水(认领通知单)多次认领与作废口径梳理 + +> 日期:2026-10-16 +> 范围:`tms-api` blade-transport 收款流水认领、认领记录模块;`tms-erp-web` 认领记录页面 + +--- + +## 一、结论速览 + +| 问题 | 结论 | +| --- | --- | +| 一个通知单能被多次认领,每次认领生成一条还是多条认领记录? | **每次认领操作生成一条认领主单**(`blade_receipt_claim`),多次认领即多条主单;每次认领同时按所选拆分生成 N 条结算单分摊明细(`blade_receipt_claim_settlement`) | +| 认领记录页面是一条一条显示还是只显示一条? | **一条认领主单一行,逐条显示**,不按通知单合并;同一通知单认领 3 次就显示 3 行,每行"认领金额"只是该次认领的金额 | +| 作废是逐条作废归还金额,还是整个通知单全部作废? | **逐条作废、按条归还金额**:作废入口在单条认领记录上,回退金额 = 该条认领主单的 `claim_amount`,流水已认领金额只减这一条;其余认领记录不受影响 | + +--- + +## 二、数据模型 + +``` +blade_kingdee_receipt_flow(收款流水 / 认领通知单,1) + │ receipt_flow_id + ├─< blade_receipt_claim(认领主单,N) ← 每次认领一条 + │ claim_amount 本次认领总金额 + │ claimer_id 认领人(记录页面按本人过滤) + │ claim_status claimed / voided + │ voided_by / voided_time 作废人 / 作废时间 + │ + └─< blade_receipt_claim_settlement(分摊明细,N × M) ← 每次认领按结算单拆行 + receipt_claim_id 归属哪次认领 + formal_settlement_id/no 关联应收正式结算单 + allocated_receipt_amount 本次分摊金额 + claimed_receipt_amount 认领前该结算单已认领金额 + status 1 有效 / 0 无效(作废置 0,不物理删除) +``` + +关键点:**认领主单的粒度是"一次认领操作"**,不是通知单。通知单维度只有累计金额(`claimed_amount`)和状态(`claim_status`:unclaimed / partial / claimed),由每次认领/作废累加回退得出。 + +--- + +## 三、认领:一次操作生成一条主单 + N 条分摊 + +代码:`ReceiptFlowServiceImpl#claim(ReceiptClaimRequest)` + +1. 锁定流水(通知单)与所选结算单。 +2. 校验两条累计上限: + - 每张结算单:已有累计认领 + 本次分摊 ≤ 结算总应收含税金额; + - 流水:已有累计认领(`flowClaimedAmount`,只算未作废记录)+ 本次总额 ≤ 公告未认领金额(`unclaimed_amount`,A301-2 口径)。 +3. **插入一条** `ReceiptClaim` 主单:`claim_amount` = 本次分摊总额,`claim_status = claimed`。 +4. 按所选结算单逐张**插入 N 条** `ReceiptClaimSettlement` 分摊明细。 +5. 累加流水 `claimed_amount`,按累计值重算流水状态(< 未认领金额 → partial,= → claimed)。 +6. 只登记本地,不推送金蝶;推送由定时任务/手动按钮触发。 + +所以:**同一通知单第二次认领是再插一条新主单**,不存在"找已有主单合并金额"的逻辑;校验全部按"未作废记录累计"实时计算,天然支持多次认领。 + +### 认领按钮的显示条件(为什么有些行没有"认领") + +收款流水列表操作列(`receipt-flow.vue` → `canClaim(row)`): + +```js +canClaim(row) { + return this.hasPermission('receipt_flow_claim') && Number(row.remainingAmount || 0) > 0; +} +``` + +两个条件同时满足才显示"认领"按钮: + +1. **按钮权限**:当前用户拥有 `receipt_flow_claim` 按钮权限(admin 直接放行,普通用户取决于菜单按钮配置); +2. **剩余可认领金额 > 0**:`remainingAmount` 由后端 `ReceiptFlowWrapper` 统一计算: + + ``` + remainingAmount = max(公告未认领金额 unclaimed_amount − 累计认领金额 claimed_amount, 0) + ``` + +按认领状态换算: + +| 流水认领状态 | remainingAmount | 认领按钮 | +| --- | --- | --- | +| unclaimed 未认领(公告金额 > 0) | = 公告未认领金额 > 0 | 显示 | +| partial 部分认领 | > 0 | 显示 | +| claimed 认领完成 | = 0(累计 ≥ 公告金额) | **不显示** | +| 公告未认领金额为 0 的流水 | = 0 | **不显示** | +| 无 `receipt_flow_claim` 权限 | — | **不显示** | + +即:**按钮显隐只看金额余额与权限,不看认领状态字段本身**;`claimed` 状态的行因为剩余额度为 0 自然不显示按钮。按钮不显示且不满足"推送金蝶"条件时,操作列显示"-"。 + +注意:顶部"批量认领"按钮只判断权限与是否勾选行(`receipt_flow_claim` + `selectedRows.length`),**不按行剩余金额过滤**;对无剩余额度的流水发起批量认领时,进入批量页面后由提交校验/后端 `claim()` 兜底拦截。 + +--- + +## 四、认领记录页面:逐条显示 + +后端:`ReceiptClaimMapper.xml#selectClaimRecordPage` → `FROM blade_receipt_claim c JOIN blade_kingdee_receipt_flow f`,固定条件 `c.claimer_id = 当前用户`。 + +- **列表一行 = 一条认领主单**,无按流水/通知单的 GROUP BY 合并; +- 同一通知单多次认领 → 多行,各行的认领金额、认领日期、金蝶单号都是当次认领自己的; +- 关联结算单列用 `GROUP_CONCAT(分摊明细.结算单号)` 拼成一串展示(只展示,不参与合并); +- 详情页(`selectClaimRecordDetail` + 分摊明细列表)展示的也是**该次认领**的分摊明细,明细里的"认领前已认领金额"记录的是认领那一刻结算单的累计值,可还原多次认领的先后关系; +- 前端 `receipt-claim-record.vue` 列表 + 行级"作废"按钮(`api.voidClaim(row.id)`),确认文案为"作废后将回退本次认领金额"——与逐条口径一致。 + +另:收款流水列表的详情弹窗里有一个"认领记录"区块,展示的是该流水的**操作日志**(`blade_receipt_flow_record`,action_type = claim/push),按时间线展示每次认领/推送动作,同样不是合并视图。 + +--- + +## 五、作废:逐条作废、按条归还金额 + +代码:`ReceiptClaimRecordServiceImpl#voidClaim(Long id)`,入参就是**单条认领主单 id**。 + +流程(单事务): + +1. 锁定该条认领主单,校验:本人记录、`claim_status = claimed`(已作废不可再作废)。 +2. 锁定关联流水,校验:**未推送金蝶**——已推送的不允许作废,提示"请在金蝶侧人工处置"。 +3. 取该主单名下有效分摊明细(status=1),全部置 `status = 0`(保留审计,不删行)。 +4. 批量刷新相关结算单收款汇总(`refreshPaymentSummaries`)。 +5. **流水 `claimed_amount` 只减去本条主单的 `claim_amount`**(下限 0),并按减后累计重算流水认领状态(0 → unclaimed,< 未认领金额 → partial,≥ → claimed)。 +6. 主单置 `voided`,记录作废人、作废时间;写 `void_claim` 操作日志(金额 = 本次回退金额)。 +7. **不生成金蝶认领冲单**:金蝶单号/状态保留原值,金蝶侧冲销/红红由人工处置;冲单逻辑代码已注释保留(`buildKingdeeBillNo`),后续迭代可放开。 + +边界与影响: + +- 作废某一条,只归还这一条的金额,**同一通知单的其他认领记录不受影响**,仍正常显示、仍可单独作废; +- 作废后该条记录仍在列表中(状态"已作废"),不允许再改附件、不可重复作废; +- 归还后的金额空间立即可被再次认领(认领校验按未作废累计实时计算); +- 附件/操作日志/分摊明细均保留,金额回退可追溯。 + +--- + +## 六、一句话总结 + +通知单(收款流水)是"累计额度"维度,认领记录是"每次操作"维度:**认领一次记一条、页面逐条展示、作废按条回退金额**;整单性质的只有流水上的累计已认领金额与认领状态,由各条记录的认领/作废实时推导,没有任何"按通知单整体作废"的入口。 diff --git a/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md new file mode 100644 index 00000000..a33bdcc2 --- /dev/null +++ b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md @@ -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)。 + diff --git a/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md new file mode 100644 index 00000000..16522c1b --- /dev/null +++ b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md @@ -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 不自动冲销金蝶单据,也不会因为调整单驳回/删除而自动恢复冲销——所以建单阶段只做软提示,把硬拦截留在重推。 diff --git a/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md b/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md new file mode 100644 index 00000000..6c3805c7 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md @@ -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 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 settlementIds); +/** 按付款申请删除核销行,返回受影响的正式结算单id(settlementType=formal,供汇总刷新) */ +List deleteByPaymentApplicationIds(Collection 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 sumBySettlementIds(String settlementType, Collection 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 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 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 双刷新为有意设计。 | diff --git a/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md b/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md new file mode 100644 index 00000000..25cf41f8 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md @@ -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. 金蝶侧预付抵扣字段未传,金蝶报表与本系统"已付含核销"存在时间差。 diff --git a/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md b/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md new file mode 100644 index 00000000..63ac715b --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md @@ -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` 源码注释。 diff --git a/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md new file mode 100644 index 00000000..2dbb4807 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md @@ -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" +- "请选择受票方有效的开票信息" +- "未找到正式结算单受票方对应的客商档案" + +**状态变化** +| 对象 | 状态变化 | +|---|---| +| 开票申请 | 审批 `草稿 → 审批中 → 审批通过`;金蝶 `未同步 → 已同步 → 开票完成` | +| 来源正式结算单 | 发票状态 `未开票 → 部分开票 → 已开票`(按分摊与回写自动算) | + +**看发票结果**:开票申请详情页的发票信息里能看到发票号、开票日期、价税合计、发票文件。金蝶回写是定时的,没出来可以先等,或在开票申请列表点「**批量同步**」推一把。 + +**限制**:开票申请一旦推到金蝶(已同步/开票完成)就**锁定不能改**;数电票已经开出来的只能走红冲,不能在本系统作废。**预付和核销不影响开票金额**,开票始终按结算单的**全额**来匹配。 + +--- + +## 四、作废与撤销:能做什么、状态怎么变 + +| 我想…… | 前提 | 操作位置 | 状态变化 | +|---|---|---|---| +| 作废付款申请 | 状态=审批通过,且**未被金蝶锁定** | 付款申请列表「作废」 | 单据→已作废;**未付的预付记录联动作废,相关核销行被删除**,受影响结算单的已付金额回落(已付出去的钱不会回退) | +| 作废正式结算单 | 未审批通过,或审批通过但**未锁定** | 正式结算单列表「作废」 | 单据→已作废;本单的核销行被删除,可核销额度释放 | +| 作废预结算单 | 无关联预付申请(有则先作废预付) | 预结算单列表「作废」 | 单据→已作废;预结算上的核销行被删除 | +| 作废开票申请 | 状态=审批通过,且**未锁定**(即还没推金蝶/还没开票) | 开票申请列表「作废」 | 单据→已作废 | +| 删除 | 状态=草稿 | 各列表「删除」 | 草稿被删除 | +| 金蝶侧反审核删单 | — | 金蝶操作后由系统处理 | 付款申请金蝶状态→生成失败,金蝶单号被清空;**未付的预付记录联动作废**;人工核实后重推,重推成功会重新建档;**已核销的行不会自动删**,需要人工作废该预付款才回落 | + +> 一句话记住:**预付一旦真付出去(已付款),作废不会把钱退回**,只会把台账和核销额度清掉。 + +--- + +## 五、操作者常见疑问 + +| 问题 | 答案 | +|---|---| +| 为什么选不到某张预结算单发起预付? | 需要**应付**、**审批通过**、**未转正式结算**、且所选单据**同一合同**。 | +| 为什么核销弹窗里找不到那笔项目预付? | 它必须已**金蝶付款完成(已付款)**,且与结算单**同一合同**、还有剩余可核销额。 | +| 为什么新增开票申请时选不到某张应收结算单? | 需要**审批通过、未作废、发票状态=未开票**;已被别的开票申请占用的会扣掉可开票额。 | +| 开票提示要先去同步金蝶? | 正式结算开票前,必须先在该应收结算单上点「同步金蝶」生成金蝶应收单。 | +| 付款申请审批通过了,但金蝶那边没有单? | 看金蝶状态:若"生成失败",点「批量同步」重推或等自动重推。 | +| 结算单已付金额比我算的多/少? | 已付金额**含**进度预付、项目预付核销;核销金额同时计入"已付"和"剩余可付"的计算,属正常口径。 | +| 结算单金额写错了怎么改? | 审批通过后不能直接改,走「结算调整单」。 | +| 单据上的「作废」是灰的? | 可能已被金蝶锁定,或状态不满足(只有审批通过且未锁定才能作废)。 | + +--- + +## 六、一眼看懂整个流程(操作版) + +``` +【应付·进度预付】 +预结算单(审批通过) →「预付申请」→ 付款申请(提交→通过) → 金蝶付款(已付款) + └ 预结算单出现"已预付金额" + +【应付·项目预付】 +付款申请页「预付申请」→ 选项目+承运商合同 → 提交→通过 → 金蝶付款(已付款) + +【预结算 → 正式结算(容纳预付)】 +预结算单(审批通过·同项目) →「尾款结算」→ 正式结算单 + 结算合计(全额) / 已付(含预付、核销) / 剩余未付 + 项目预付核销自动带入 → 保存 → 提交 → 通过 + └ 「同步金蝶」(应付单已同步) + +【应付·尾款】 +正式结算单(应付·审批通过) →「付款申请」→ 付款申请 → 金蝶应付下推 → 已付款 + └ 结算单收付款状态:未付款 → 部分付款 → 已付款 + +【应收·开票】 +应收正式结算单(审批通过·未开票) →「同步金蝶」(应收单已同步) + →「开票申请」新增 → 选结算单 → 填开票内容与分摊 → 提交 → 通过 + → 金蝶开票(已同步 → 开票完成) → 结算单发票状态:未开票 → 部分开票 → 已开票 +``` diff --git a/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md new file mode 100644 index 00000000..a61724d0 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md @@ -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)与收票流程未在本文展开。 diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index caa5f604..6e390a3d 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -57,6 +57,7 @@ thirdParty: # 国内运输:运单运输方式 -> 金蝶物料编码(公路运输/铁路运输/水路运输/航空运输四种) domestic: "[公路运输]": ${BFI_MATERIAL_DOMESTIC_ROAD:XDWL11010001} + "[road]": ${BFI_MATERIAL_DOMESTIC_ROAD:XDWL11010001} "[铁路运输]": ${BFI_MATERIAL_DOMESTIC_RAIL:XDWL11010002} "[水路运输]": ${BFI_MATERIAL_DOMESTIC_WATER:XDWL11010003} "[航空运输]": ${BFI_MATERIAL_DOMESTIC_AIR:Item-00001082} @@ -118,6 +119,8 @@ thirdParty: incrementDays: ${BFI_INVOICE_RECEIPT_INCREMENT_DAYS:30} # 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 invoiceTypes: ${BFI_INVOICE_RECEIPT_INVOICE_TYPES:26,27} + # 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在客商发票信息中的发票,前端票池展示查询同用此开关过滤 + customerUnitsOnly: ${BFI_INVOICE_RECEIPT_CUSTOMER_UNITS_ONLY:false} # 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) compensateLimit: ${BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT:200} # single-flight 分布式锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 diff --git a/doc/sql/bladex/bladex.mysql.all.create.sql b/doc/sql/bladex/bladex.mysql.all.create.sql index 67ea9959..7a6e69cc 100644 --- a/doc/sql/bladex/bladex.mysql.all.create.sql +++ b/doc/sql/bladex/bladex.mysql.all.create.sql @@ -1324,7 +1324,9 @@ CREATE TABLE `blade_user_dept` ( `oa_id` varchar(64) NULL DEFAULT NULL COMMENT 'OA人员ID', `oa_login_id` varchar(128) NULL DEFAULT NULL COMMENT 'OA登录账号', `sync_time` datetime NULL DEFAULT NULL COMMENT '同步时间', - PRIMARY KEY (`id`) USING BTREE + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_blade_user_dept_user` (`user_id`, `is_deleted`, `status`) USING BTREE, + INDEX `idx_blade_user_dept_dept` (`dept_id`, `is_deleted`, `status`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户部门表'; -- ---------------------------- diff --git a/doc/sql/transport/blade_formal_settlement_20260818.sql b/doc/sql/transport/blade_formal_settlement_20260818.sql index 98ccfc06..240eafcb 100644 --- a/doc/sql/transport/blade_formal_settlement_20260818.sql +++ b/doc/sql/transport/blade_formal_settlement_20260818.sql @@ -44,7 +44,9 @@ CREATE TABLE IF NOT EXISTS `blade_formal_settlement_change_record` ( `update_user` bigint(20) DEFAULT NULL, `update_time` datetime DEFAULT NULL, `status` int(11) DEFAULT '1', `is_deleted` int(11) DEFAULT '0', `formal_settlement_id` bigint(20) NOT NULL COMMENT '正式结算单ID', `change_type` varchar(50) NOT NULL COMMENT '变更类型', `line_no` int(11) DEFAULT NULL COMMENT '行号', `operation_type` varchar(30) NOT NULL COMMENT '操作类型', - `change_content` text NOT NULL COMMENT '变更内容', `change_reason` varchar(200) DEFAULT NULL COMMENT '变更原因', + `change_content` text NOT NULL COMMENT '变更内容', + `before_data` longtext COMMENT '变更前数据JSON', `after_data` longtext COMMENT '变更后数据JSON', + `change_reason` varchar(200) DEFAULT NULL COMMENT '变更原因', `operator_name` varchar(100) DEFAULT NULL COMMENT '操作人', `change_time` datetime NOT NULL COMMENT '变更时间', PRIMARY KEY (`id`), KEY `idx_formal_settlement_change_bill` (`formal_settlement_id`), KEY `idx_formal_settlement_change_time` (`change_time`) @@ -123,17 +125,29 @@ CREATE TABLE IF NOT EXISTS `blade_formal_settlement_payment` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='正式结算付款申请'; INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES -(2090000000000001040,2090000000000001000,'formal_settlement','正式结算单','formal_settlement','/settlement/formal-settlement','iconfont icon-caidanguanli',4,1,0,1,NULL,'',0), -(2090000000000001041,2090000000000001040,'formal_settlement_view','查看','formal_settlement_view','','',1,2,0,1,NULL,'',0), -(2090000000000001042,2090000000000001040,'formal_settlement_add','新增','formal_settlement_add','','',2,2,0,1,NULL,'',0), -(2090000000000001043,2090000000000001040,'formal_settlement_edit','编辑','formal_settlement_edit','','',3,2,0,1,NULL,'',0), -(2090000000000001044,2090000000000001040,'formal_settlement_delete','删除','formal_settlement_delete','','',4,2,0,1,NULL,'',0), -(2090000000000001045,2090000000000001040,'formal_settlement_submit','提交审批','formal_settlement_submit','','',5,2,0,1,NULL,'',0), -(2090000000000001046,2090000000000001040,'formal_settlement_approve','审批','formal_settlement_approve','','',6,2,0,1,NULL,'',0), -(2090000000000001047,2090000000000001040,'formal_settlement_sync','同步金蝶','formal_settlement_sync','','',7,2,0,1,NULL,'',0), -(2090000000000001048,2090000000000001040,'formal_settlement_print','打印','formal_settlement_print','','',8,2,0,1,NULL,'',0), -(2090000000000001049,2090000000000001040,'formal_settlement_export','导出','formal_settlement_export','','',9,2,0,1,NULL,'',0), -(2090000000000001050,2090000000000001040,'formal_settlement_void','作废','formal_settlement_void','','',10,2,0,1,NULL,'',0), -(2090000000000001051,2090000000000001040,'formal_settlement_adjust','明细调整','formal_settlement_adjust','','',11,2,0,1,NULL,'',0), -(2090000000000001052,2090000000000001040,'formal_settlement_payment','付款申请','formal_settlement_payment','','',12,2,0,1,NULL,'',0) +(2090000000000001040,2090000000000001000,'payment_settlement','付款结算单','payment_settlement','/settlement/payment-settlement','iconfont icon-caidanguanli',4,1,0,1,NULL,'',0), +(2090000000000001041,2090000000000001040,'payment_settlement_view','查看','payment_settlement_view','','',1,2,0,1,NULL,'',0), +(2090000000000001042,2090000000000001040,'payment_settlement_add','新增','payment_settlement_add','','',2,2,0,1,NULL,'',0), +(2090000000000001043,2090000000000001040,'payment_settlement_edit','编辑','payment_settlement_edit','','',3,2,0,1,NULL,'',0), +(2090000000000001044,2090000000000001040,'payment_settlement_delete','删除','payment_settlement_delete','','',4,2,0,1,NULL,'',0), +(2090000000000001045,2090000000000001040,'payment_settlement_submit','提交审批','payment_settlement_submit','','',5,2,0,1,NULL,'',0), +(2090000000000001046,2090000000000001040,'payment_settlement_approve','审批','payment_settlement_approve','','',6,2,0,1,NULL,'',0), +(2090000000000001047,2090000000000001040,'payment_settlement_sync','同步金蝶','payment_settlement_sync','','',7,2,0,1,NULL,'',0), +(2090000000000001048,2090000000000001040,'payment_settlement_print','打印','payment_settlement_print','','',8,2,0,1,NULL,'',0), +(2090000000000001049,2090000000000001040,'payment_settlement_export','导出','payment_settlement_export','','',9,2,0,1,NULL,'',0), +(2090000000000001050,2090000000000001040,'payment_settlement_void','作废','payment_settlement_void','','',10,2,0,1,NULL,'',0), +(2090000000000001051,2090000000000001040,'payment_settlement_adjust','明细调整','payment_settlement_adjust','','',11,2,0,1,NULL,'',0), +(2090000000000001052,2090000000000001040,'payment_settlement_payment','付款申请','payment_settlement_payment','','',12,2,0,1,NULL,'',0), +(2090000000000002100,2090000000000001000,'receipt_settlement','收款结算单','receipt_settlement','/settlement/receipt-settlement','iconfont icon-caidanguanli',5,1,0,1,NULL,'',0), +(2090000000000002101,2090000000000002100,'receipt_settlement_view','查看','receipt_settlement_view','','',1,2,0,1,NULL,'',0), +(2090000000000002102,2090000000000002100,'receipt_settlement_add','新增','receipt_settlement_add','','',2,2,0,1,NULL,'',0), +(2090000000000002103,2090000000000002100,'receipt_settlement_edit','编辑','receipt_settlement_edit','','',3,2,0,1,NULL,'',0), +(2090000000000002104,2090000000000002100,'receipt_settlement_delete','删除','receipt_settlement_delete','','',4,2,0,1,NULL,'',0), +(2090000000000002105,2090000000000002100,'receipt_settlement_submit','提交审批','receipt_settlement_submit','','',5,2,0,1,NULL,'',0), +(2090000000000002106,2090000000000002100,'receipt_settlement_approve','审批','receipt_settlement_approve','','',6,2,0,1,NULL,'',0), +(2090000000000002107,2090000000000002100,'receipt_settlement_sync','同步金蝶','receipt_settlement_sync','','',7,2,0,1,NULL,'',0), +(2090000000000002108,2090000000000002100,'receipt_settlement_print','打印','receipt_settlement_print','','',8,2,0,1,NULL,'',0), +(2090000000000002109,2090000000000002100,'receipt_settlement_export','导出','receipt_settlement_export','','',9,2,0,1,NULL,'',0), +(2090000000000002110,2090000000000002100,'receipt_settlement_void','作废','receipt_settlement_void','','',10,2,0,1,NULL,'',0), +(2090000000000002111,2090000000000002100,'receipt_settlement_adjust','明细调整','receipt_settlement_adjust','','',11,2,0,1,NULL,'',0) ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; diff --git a/doc/sql/transport/blade_formal_settlement_change_record_20260827.sql b/doc/sql/transport/blade_formal_settlement_change_record_20260827.sql index 08ba660e..bf2abb67 100644 --- a/doc/sql/transport/blade_formal_settlement_change_record_20260827.sql +++ b/doc/sql/transport/blade_formal_settlement_change_record_20260827.sql @@ -14,6 +14,8 @@ CREATE TABLE IF NOT EXISTS `blade_formal_settlement_change_record` ( `line_no` int(11) DEFAULT NULL COMMENT '行号', `operation_type` varchar(30) COLLATE utf8mb4_general_ci NOT NULL COMMENT '操作类型', `change_content` text COLLATE utf8mb4_general_ci NOT NULL COMMENT '变更内容', + `before_data` longtext COLLATE utf8mb4_general_ci COMMENT '变更前数据JSON', + `after_data` longtext COLLATE utf8mb4_general_ci COMMENT '变更后数据JSON', `change_reason` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '变更原因', `operator_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作人', `change_time` datetime NOT NULL COMMENT '变更时间', diff --git a/doc/sql/transport/blade_formal_settlement_change_record_detail_20261018.sql b/doc/sql/transport/blade_formal_settlement_change_record_detail_20261018.sql new file mode 100644 index 00000000..65eb28e7 --- /dev/null +++ b/doc/sql/transport/blade_formal_settlement_change_record_detail_20261018.sql @@ -0,0 +1,30 @@ +-- 正式结算变更记录补充前后值明细,与预结算变更记录口径一致。 +SET @before_data_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement_change_record' + AND COLUMN_NAME = 'before_data' +); +SET @before_data_sql := IF( + @before_data_exists = 0, + 'ALTER TABLE `blade_formal_settlement_change_record` ADD COLUMN `before_data` longtext COLLATE utf8mb4_general_ci COMMENT ''变更前数据JSON'' AFTER `change_content`', + 'SELECT 1' +); +PREPARE before_data_stmt FROM @before_data_sql; +EXECUTE before_data_stmt; +DEALLOCATE PREPARE before_data_stmt; + +SET @after_data_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement_change_record' + AND COLUMN_NAME = 'after_data' +); +SET @after_data_sql := IF( + @after_data_exists = 0, + 'ALTER TABLE `blade_formal_settlement_change_record` ADD COLUMN `after_data` longtext COLLATE utf8mb4_general_ci COMMENT ''变更后数据JSON'' AFTER `before_data`', + 'SELECT 1' +); +PREPARE after_data_stmt FROM @after_data_sql; +EXECUTE after_data_stmt; +DEALLOCATE PREPARE after_data_stmt; diff --git a/doc/sql/transport/blade_payment_application_settlement_pre_20261016.sql b/doc/sql/transport/blade_payment_application_settlement_pre_20261016.sql new file mode 100644 index 00000000..1cc6e9e9 --- /dev/null +++ b/doc/sql/transport/blade_payment_application_settlement_pre_20261016.sql @@ -0,0 +1,82 @@ +-- 付款申请可关联多张预结算单:关联表补充预结算字段,正式结算字段改为可空 + +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_payment_application_settlement` MODIFY COLUMN `formal_settlement_id` bigint(20) NULL COMMENT ''正式结算单ID''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'formal_settlement_id' + AND IS_NULLABLE = 'NO' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_payment_application_settlement` MODIFY COLUMN `formal_settlement_no` varchar(100) COLLATE utf8mb4_general_ci NULL COMMENT ''正式结算单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'formal_settlement_no' + AND IS_NULLABLE = 'NO' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application_settlement` ADD COLUMN `pre_settlement_id` bigint(20) DEFAULT NULL COMMENT ''预结算单ID'' AFTER `formal_settlement_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'pre_settlement_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application_settlement` ADD COLUMN `pre_settlement_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''预结算单号'' AFTER `pre_settlement_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'pre_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_payment_application_settlement` ADD KEY `idx_payment_settlement_pre` (`pre_settlement_id`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND INDEX_NAME = 'idx_payment_settlement_pre' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_payment_application` MODIFY COLUMN `pre_settlement_no` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''预结算单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application' + AND COLUMN_NAME = 'pre_settlement_no' + AND CHARACTER_MAXIMUM_LENGTH < 500 +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_pre_settlement_20260818.sql b/doc/sql/transport/blade_pre_settlement_20260818.sql index 2079beb9..6f20b639 100644 --- a/doc/sql/transport/blade_pre_settlement_20260818.sql +++ b/doc/sql/transport/blade_pre_settlement_20260818.sql @@ -200,7 +200,7 @@ CREATE TABLE IF NOT EXISTS `blade_pre_settlement_change_record` ( INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES -(2090000000000001020, 2090000000000001000, 'pre_settlement', '预结算', 'pre_settlement', '/settlement/pre-settlement', 'iconfont icon-caidanguanli', 3, 1, 0, 1, NULL, '', 0), +(2090000000000001020, 2090000000000001000, 'pre_settlement', '预付结算单', 'pre_settlement', '/settlement/pre-settlement', 'iconfont icon-caidanguanli', 3, 1, 0, 1, NULL, '', 0), (2090000000000001021, 2090000000000001020, 'pre_settlement_view', '查看', 'pre_settlement_view', '', '', 1, 2, 0, 1, NULL, '', 0), (2090000000000001022, 2090000000000001020, 'pre_settlement_add', '新增', 'pre_settlement_add', '', '', 2, 2, 0, 1, NULL, '', 0), (2090000000000001023, 2090000000000001020, 'pre_settlement_edit', '编辑', 'pre_settlement_edit', '', '', 3, 2, 0, 1, NULL, '', 0), diff --git a/doc/sql/transport/blade_settlement_adjustment_kingdee_20261016.sql b/doc/sql/transport/blade_settlement_adjustment_kingdee_20261016.sql new file mode 100644 index 00000000..67f5cc02 --- /dev/null +++ b/doc/sql/transport/blade_settlement_adjustment_kingdee_20261016.sql @@ -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 初始化,本脚本无需新增菜单。 diff --git a/doc/sql/transport/blade_settlement_import_batch_20261015.sql b/doc/sql/transport/blade_settlement_import_batch_20261015.sql index 6578be14..89c7b872 100644 --- a/doc/sql/transport/blade_settlement_import_batch_20261015.sql +++ b/doc/sql/transport/blade_settlement_import_batch_20261015.sql @@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `blade_settlement_import_batch` ( `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID', `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号', `fee_item_names_json` longtext COMMENT '动态费用列名JSON数组', + `rows_json` longtext COMMENT '导入明细行JSON数组', `remark` varchar(200) DEFAULT NULL COMMENT '备注', `waybill_import_batch_id` bigint DEFAULT NULL COMMENT '关联底层运单导入批次,可空', `create_user` bigint DEFAULT NULL COMMENT '创建人', @@ -45,6 +46,7 @@ ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `settlement_bill_type` va ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID' AFTER `formal_settlement_no`; ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号' AFTER `pre_settlement_id`; ALTER TABLE `blade_settlement_import_batch` ADD KEY `idx_settlement_import_pre` (`pre_settlement_id`); +ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`; -- 运单关联导入结算批次(可选,若列已存在可忽略报错) ALTER TABLE `blade_waybill` ADD COLUMN `settlement_import_batch_id` bigint DEFAULT NULL COMMENT '导入结算单批次ID'; @@ -56,5 +58,11 @@ INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source` (2090000000000002082,2090000000000002080,'settlement_import_add','新增','settlement_import_add','','',2,2,0,1,NULL,'',0), (2090000000000002083,2090000000000002080,'settlement_import_detail','详情','settlement_import_detail','','',3,2,0,1,NULL,'',0), (2090000000000002084,2090000000000002080,'settlement_import_void','作废','settlement_import_void','','',4,2,0,1,NULL,'',0), -(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0) +(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0), +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; + +-- 已建表环境增量菜单(已存在可忽略) +INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; diff --git a/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql b/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql new file mode 100644 index 00000000..218ed51e --- /dev/null +++ b/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql @@ -0,0 +1,7 @@ +-- 导入结算单:草稿明细落库 + 删除菜单 +ALTER TABLE `blade_settlement_import_batch` + ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`; + +INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; diff --git a/doc/sql/transport/blade_settlement_menu_split_20261018.sql b/doc/sql/transport/blade_settlement_menu_split_20261018.sql new file mode 100644 index 00000000..27df1058 --- /dev/null +++ b/doc/sql/transport/blade_settlement_menu_split_20261018.sql @@ -0,0 +1,163 @@ +-- 结算菜单调整: +-- 1)预结算 → 预付结算单 +-- 2)正式结算单拆分为:付款结算单(应付)、收款结算单(应收) +-- 执行后需重新登录;已授权「正式结算单/付款结算单」的角色会同步授权「收款结算单」。 + +-- ① 预结算改名 +UPDATE `blade_menu` +SET `name` = '预付结算单' +WHERE `code` = 'pre_settlement' + AND `is_deleted` = 0; + +-- ② 原正式结算单菜单改为「付款结算单」(保留原菜单 id,角色授权不丢) +UPDATE `blade_menu` +SET `code` = 'payment_settlement', + `name` = '付款结算单', + `alias` = 'payment_settlement', + `path` = '/settlement/payment-settlement', + `is_deleted` = 0 +WHERE `code` IN ('formal_settlement', 'payment_settlement') + AND `category` = 1 + AND `is_deleted` = 0; + +-- 付款结算单按钮权限码:formal_settlement_* → payment_settlement_* +UPDATE `blade_menu` child +INNER JOIN `blade_menu` parent + ON parent.`id` = child.`parent_id` + AND parent.`code` = 'payment_settlement' + AND parent.`is_deleted` = 0 +SET child.`code` = REPLACE(child.`code`, 'formal_settlement_', 'payment_settlement_'), + child.`alias` = REPLACE(IFNULL(child.`alias`, child.`code`), 'formal_settlement_', 'payment_settlement_'), + child.`is_deleted` = 0 +WHERE child.`category` = 2 + AND child.`code` LIKE 'formal_settlement_%'; + +-- ③ 新增「收款结算单」菜单 +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT + 2090000000000002100, + parent.`parent_id`, + 'receipt_settlement', + '收款结算单', + 'receipt_settlement', + '/settlement/receipt-settlement', + IFNULL(parent.`source`, 'iconfont icon-caidanguanli'), + IFNULL(parent.`sort`, 4) + 1, + 1, 0, 1, NULL, '', 0 +FROM `blade_menu` parent +WHERE parent.`code` = 'payment_settlement' + AND parent.`is_deleted` = 0 + AND NOT EXISTS ( + SELECT 1 FROM `blade_menu` x WHERE x.`code` = 'receipt_settlement' AND x.`is_deleted` = 0 + ) +LIMIT 1; + +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002101, p.id, 'receipt_settlement_view', '查看', 'receipt_settlement_view', '', '', 1, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_view' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002102, p.id, 'receipt_settlement_add', '新增', 'receipt_settlement_add', '', '', 2, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_add' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002103, p.id, 'receipt_settlement_edit', '编辑', 'receipt_settlement_edit', '', '', 3, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_edit' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002104, p.id, 'receipt_settlement_delete', '删除', 'receipt_settlement_delete', '', '', 4, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_delete' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002105, p.id, 'receipt_settlement_submit', '提交审批', 'receipt_settlement_submit', '', '', 5, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_submit' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002106, p.id, 'receipt_settlement_approve', '审批', 'receipt_settlement_approve', '', '', 6, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_approve' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002107, p.id, 'receipt_settlement_sync', '同步金蝶', 'receipt_settlement_sync', '', '', 7, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_sync' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002108, p.id, 'receipt_settlement_print', '打印', 'receipt_settlement_print', '', '', 8, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_print' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002109, p.id, 'receipt_settlement_export', '导出', 'receipt_settlement_export', '', '', 9, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_export' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002110, p.id, 'receipt_settlement_void', '作废', 'receipt_settlement_void', '', '', 10, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_void' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002111, p.id, 'receipt_settlement_adjust', '明细调整', 'receipt_settlement_adjust', '', '', 11, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_adjust' AND x.is_deleted = 0) +LIMIT 1; + +-- ④ 角色授权:持有付款结算单的角色同步收款结算单(含按钮) +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT + 2091200000000000000 + CAST(rm.`role_id` AS UNSIGNED) % 100000, + receipt.`id`, + rm.`role_id` +FROM `blade_role_menu` rm +JOIN `blade_menu` payment ON payment.`id` = rm.`menu_id` + AND payment.`code` = 'payment_settlement' + AND payment.`is_deleted` = 0 +JOIN `blade_menu` receipt ON receipt.`code` = 'receipt_settlement' + AND receipt.`is_deleted` = 0 +WHERE NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = receipt.`id` AND x.`role_id` = rm.`role_id` +); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT + 2091300000000000000 + + (CAST(rm.`role_id` AS UNSIGNED) % 100000) * 20 + + (CAST(receipt_btn.`id` AS UNSIGNED) % 20), + receipt_btn.`id`, + rm.`role_id` +FROM `blade_role_menu` rm +JOIN `blade_menu` payment_btn ON payment_btn.`id` = rm.`menu_id` + AND payment_btn.`code` LIKE 'payment_settlement_%' + AND payment_btn.`is_deleted` = 0 +JOIN `blade_menu` receipt_btn + ON receipt_btn.`code` = REPLACE(payment_btn.`code`, 'payment_settlement_', 'receipt_settlement_') + AND receipt_btn.`is_deleted` = 0 +WHERE NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = receipt_btn.`id` AND x.`role_id` = rm.`role_id` +); + +-- ⑤ 残留旧正式结算菜单隐藏 +UPDATE `blade_menu` +SET `is_deleted` = 1 +WHERE `code` = 'formal_settlement' + AND `category` = 1; diff --git a/doc/sql/transport/blade_user_dept_index_20261019.sql b/doc/sql/transport/blade_user_dept_index_20261019.sql new file mode 100644 index 00000000..5ac5e8c7 --- /dev/null +++ b/doc/sql/transport/blade_user_dept_index_20261019.sql @@ -0,0 +1,6 @@ +-- 用户部门关联表索引:加速人员列表按组织树过滤 +-- 部署时在业务库执行本脚本 + +ALTER TABLE `blade_user_dept` + ADD INDEX `idx_blade_user_dept_user` (`user_id`, `is_deleted`, `status`) USING BTREE, + ADD INDEX `idx_blade_user_dept_dept` (`dept_id`, `is_deleted`, `status`) USING BTREE; diff --git a/doc/sql/transport/blade_user_oa_login_id_20261019.sql b/doc/sql/transport/blade_user_oa_login_id_20261019.sql new file mode 100644 index 00000000..21636521 --- /dev/null +++ b/doc/sql/transport/blade_user_oa_login_id_20261019.sql @@ -0,0 +1,18 @@ +-- 用户表:新增 OA 登录名,支持用户管理后台维护 +-- 排序规则 utf8mb4_general_ci + +ALTER TABLE `blade_user` + ADD COLUMN `oa_login_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA登录名' AFTER `is_oa`; + +-- 从用户部门关联表回填已有 OA 登录账号 +UPDATE `blade_user` bu +INNER JOIN ( + SELECT `user_id`, MAX(`oa_login_id`) AS `oa_login_id` + FROM `blade_user_dept` + WHERE `is_deleted` = 0 + AND `oa_login_id` IS NOT NULL + AND `oa_login_id` <> '' + GROUP BY `user_id` +) bud ON bud.`user_id` = bu.`id` +SET bu.`oa_login_id` = bud.`oa_login_id` +WHERE bu.`oa_login_id` IS NULL OR bu.`oa_login_id` = ''; diff --git a/doc/sql/transport/transport.sql b/doc/sql/transport/transport.sql index c2bc3826..b8152384 100644 --- a/doc/sql/transport/transport.sql +++ b/doc/sql/transport/transport.sql @@ -5487,7 +5487,9 @@ CREATE TABLE `blade_user_dept` ( `oa_id` varchar(64) DEFAULT NULL COMMENT 'OA人员ID', `oa_login_id` varchar(128) DEFAULT NULL COMMENT 'OA登录账号', `sync_time` datetime DEFAULT NULL COMMENT '同步时间', - PRIMARY KEY (`id`) USING BTREE + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_blade_user_dept_user` (`user_id`, `is_deleted`, `status`) USING BTREE, + INDEX `idx_blade_user_dept_dept` (`dept_id`, `is_deleted`, `status`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户部门表'; -- ---------------------------- diff --git a/pom.xml b/pom.xml index f064dd95..9a7485b3 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,9 @@ UTF-8 UTF-8 + + true + admin blade