diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractChange.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractChange.java
new file mode 100644
index 00000000..936ec485
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractChange.java
@@ -0,0 +1,228 @@
+/**
+ * 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.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+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 io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springblade.core.tenant.mp.TenantEntity;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 合同变更实体类
+ *
+ * @author Chill
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("blade_contract_change")
+@Schema(description = "合同变更")
+public class ContractChange extends TenantEntity {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "原合同ID")
+ private Long contractId;
+
+ @Schema(description = "变更编号")
+ private String changeNo;
+
+ @Schema(description = "变更类型")
+ private String changeType;
+
+ @Schema(description = "变更日期")
+ private LocalDate changeDate;
+
+ @Schema(description = "变更内容")
+ private String changeContent;
+
+ @Schema(description = "变更原因")
+ private String changeReason;
+
+ @Schema(description = "变更附件JSON")
+ private String changeAttachmentsJson;
+
+ @Schema(description = "变更前快照JSON")
+ private String beforeDataJson;
+
+ @Schema(description = "合同编号")
+ private String contractNo;
+
+ @Schema(description = "合同名称")
+ private String contractName;
+
+ @Schema(description = "所属项目ID")
+ private Long projectId;
+
+ @Schema(description = "所属项目")
+ private String projectName;
+
+ @Schema(description = "合同负责部门ID")
+ private Long organizationId;
+
+ @Schema(description = "合同负责部门")
+ private String organizationName;
+
+ @Schema(description = "合同类别")
+ private String contractCategory;
+
+ @Schema(description = "签约类型")
+ private String signType;
+
+ @Schema(description = "甲方")
+ private String partyA;
+
+ @Schema(description = "乙方")
+ private String partyB;
+
+ @Schema(description = "开始日期")
+ private LocalDate startDate;
+
+ @Schema(description = "结束日期")
+ private LocalDate endDate;
+
+ @Schema(description = "临时效力起")
+ private LocalDate temporaryStartDate;
+
+ @Schema(description = "临时效力止")
+ private LocalDate temporaryEndDate;
+
+ @Schema(description = "经办人ID")
+ private Long handlerUserId;
+
+ @Schema(description = "经办人")
+ private String handlerUserName;
+
+ @Schema(description = "签订日期")
+ private LocalDate signDate;
+
+ @Schema(description = "申请日期")
+ private LocalDate applyDate;
+
+ @Schema(description = "结算方式")
+ private String settlementMode;
+
+ @Schema(description = "合同格式")
+ private String contractFormat;
+
+ @Schema(description = "是否需要加盖法人章")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private Integer legalSealFlag;
+
+ @Schema(description = "一式份数")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private Integer copyCount;
+
+ @Schema(description = "结算币种")
+ private String settlementCurrency;
+
+ @Schema(description = "开票周期(天)")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private Integer invoiceCycle;
+
+ @Schema(description = "回款账期(天)")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private Integer paymentDays;
+
+ @Schema(description = "合同金额")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private BigDecimal contractAmount;
+
+ @Schema(description = "是否范本")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private Integer templateFlag;
+
+ @Schema(description = "原件合同编号")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private String originalContractNo;
+
+ @Schema(description = "是否电子章")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private Integer electronicSealFlag;
+
+ @Schema(description = "计费信息开关")
+ private Integer billingEnabled;
+
+ @Schema(description = "费用生成模式")
+ private String feeGenerationMode;
+
+ @Schema(description = "合同主文件JSON")
+ private String contractFileJson;
+
+ @Schema(description = "其它附件JSON")
+ private String attachmentsJson;
+
+ @Schema(description = "计费方案JSON")
+ private String billingPlanJson;
+
+ @Schema(description = "结算生成规则JSON")
+ private String settlementRuleJson;
+
+ @Schema(description = "预结算配置JSON")
+ private String preSettlementConfigJson;
+
+ @Schema(description = "正式结算配置JSON")
+ private String formalSettlementConfigJson;
+
+ @Schema(description = "付款比例设置JSON")
+ private String paymentRatioJson;
+
+ @Schema(description = "对账配置JSON")
+ private String reconciliationJson;
+
+ @Schema(description = "合同主要内容")
+ private String mainContent;
+
+ @Schema(description = "其他事项说明")
+ private String remark;
+
+ @Schema(description = "合规客商风险等级(展示用)")
+ private String hcsRiskLevel;
+
+ @Schema(description = "合规客商黑名单状态(展示用)")
+ private String hcsBlackStatus;
+
+ @Schema(description = "审批状态")
+ private String approvalStatus;
+
+ @Schema(description = "当前节点")
+ private String currentNode;
+
+ @Schema(description = "当前处理人")
+ private String currentProcessor;
+
+ @Schema(description = "审核通过时间")
+ private LocalDateTime approvedTime;
+
+ @Schema(description = "同步状态")
+ private String syncStatus;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractChangeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractChangeVO.java
new file mode 100644
index 00000000..086d498e
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractChangeVO.java
@@ -0,0 +1,70 @@
+/**
+ * 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.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.vo;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springblade.transport.pojo.entity.ContractChange;
+
+import java.io.Serial;
+
+/**
+ * 合同变更视图实体类
+ *
+ * @author Chill
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "合同变更")
+public class ContractChangeVO extends ContractChange {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @TableField(exist = false)
+ @Schema(description = "是否查看全部组织")
+ private Integer allDept;
+
+ @TableField(exist = false)
+ @Schema(description = "是否只读")
+ private Boolean readonly;
+
+ @TableField(exist = false)
+ @Schema(description = "创建人姓名")
+ private String createUserName;
+
+ @TableField(exist = false)
+ @Schema(description = "更新人姓名")
+ private String updateUserName;
+
+ @TableField(exist = false)
+ @Schema(description = "审批状态名称")
+ private String approvalStatusName;
+
+ @TableField(exist = false)
+ @Schema(description = "同步状态名称")
+ private String syncStatusName;
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractChangeController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractChangeController.java
new file mode 100644
index 00000000..89ddf9e0
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractChangeController.java
@@ -0,0 +1,157 @@
+/**
+ * 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.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+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.annotation.PreAuth;
+import org.springblade.core.tool.api.R;
+import org.springblade.transport.pojo.entity.ContractChange;
+import org.springblade.transport.pojo.vo.ContractChangeVO;
+import org.springblade.transport.service.IContractChangeService;
+import org.springblade.transport.support.hcs.HcsImmediatePushSupport;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 合同变更 控制器
+ *
+ * @author Chill
+ */
+@RestController
+@AllArgsConstructor
+@PreAuth(menu = "contract_change")
+@RequestMapping("/contract-change")
+@Tag(name = "合同变更", description = "合同变更")
+public class ContractChangeController extends BladeController {
+
+ private final IContractChangeService contractChangeService;
+ private final HcsImmediatePushSupport hcsImmediatePushSupport;
+
+ @GetMapping("/detail")
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "详情", description = "传入id")
+ public R detail(@Parameter(description = "主键", required = true) @RequestParam Long id) {
+ return R.data(contractChangeService.detail(id));
+ }
+
+ @GetMapping("/list")
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "分页", description = "传入contractChange")
+ public R> list(ContractChangeVO contractChange, Query query) {
+ return R.data(contractChangeService.selectContractChangePage(Condition.getPage(query), contractChange));
+ }
+
+ @PostMapping("/create-from-contract")
+ @ApiOperationSupport(order = 3)
+ @Operation(summary = "基于合同创建变更草稿", description = "传入contractId、changeType")
+ public R createFromContract(@Parameter(description = "合同ID", required = true) @RequestParam Long contractId,
+ @RequestParam(required = false) String changeType) {
+ return R.data(contractChangeService.createFromContract(contractId, changeType));
+ }
+
+ @PostMapping("/save-draft")
+ @ApiOperationSupport(order = 4)
+ @Operation(summary = "保存草稿", description = "传入contractChange")
+ public R saveDraft(@RequestBody ContractChange contractChange) {
+ return R.status(contractChangeService.saveDraft(contractChange));
+ }
+
+ @PostMapping("/submit")
+ @ApiOperationSupport(order = 5)
+ @Operation(summary = "保存并提交审批", description = "传入contractChange")
+ public R submit(@RequestBody ContractChange contractChange) {
+ return contractChangeService.submit(contractChange) ? R.data(contractChange) : R.fail("提交失败");
+ }
+
+ @PostMapping("/submit-approval")
+ @ApiOperationSupport(order = 6)
+ @Operation(summary = "提交审批", description = "传入id")
+ public R submitApproval(@Parameter(description = "主键", required = true) @RequestParam Long id) {
+ return R.status(contractChangeService.submitApproval(id));
+ }
+
+ @PostMapping("/approve")
+ @ApiOperationSupport(order = 7)
+ @Operation(summary = "审批通过", description = "传入id,通过后回写原合同并推送合规")
+ public R approve(@Parameter(description = "主键", required = true) @RequestParam Long id) {
+ Long contractId = contractChangeService.approve(id);
+ hcsImmediatePushSupport.pushContractAndWait(contractId);
+ String syncFailure = HcsImmediatePushSupport.pollSyncFailure();
+ if (syncFailure != null) {
+ ContractChange contractChange = contractChangeService.getById(id);
+ if (contractChange != null) {
+ contractChange.setSyncStatus("failed");
+ contractChangeService.updateById(contractChange);
+ }
+ return R.data(Boolean.FALSE, "审批通过,但合规推送失败:" + syncFailure);
+ }
+ ContractChange contractChange = contractChangeService.getById(id);
+ if (contractChange != null) {
+ contractChange.setSyncStatus("synced");
+ contractChangeService.updateById(contractChange);
+ }
+ return R.success("审批通过");
+ }
+
+ @PostMapping("/reject")
+ @ApiOperationSupport(order = 8)
+ @Operation(summary = "审批驳回", description = "传入id")
+ public R reject(@Parameter(description = "主键", required = true) @RequestParam Long id) {
+ return R.status(contractChangeService.reject(id));
+ }
+
+ @PostMapping("/withdraw")
+ @ApiOperationSupport(order = 9)
+ @Operation(summary = "撤回审批", description = "传入id")
+ public R withdraw(@Parameter(description = "主键", required = true) @RequestParam Long id) {
+ return R.status(contractChangeService.withdraw(id));
+ }
+
+ @PostMapping("/remove")
+ @ApiOperationSupport(order = 10)
+ @Operation(summary = "逻辑删除", description = "传入ids")
+ public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) {
+ return R.status(contractChangeService.removeDraft(ids));
+ }
+
+ @PostMapping("/mark-sync-status")
+ @ApiOperationSupport(order = 11)
+ @Operation(summary = "标记同步状态", description = "传入ids和syncStatus")
+ public R markSyncStatus(@Parameter(description = "主键集合", required = true) @RequestParam String ids,
+ @Parameter(description = "同步状态", required = true) @RequestParam String syncStatus) {
+ return R.status(contractChangeService.markSyncStatus(ids, syncStatus));
+ }
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ContractChangeMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ContractChangeMapper.java
new file mode 100644
index 00000000..5fd4321e
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ContractChangeMapper.java
@@ -0,0 +1,34 @@
+/**
+ * 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.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.springblade.transport.pojo.entity.ContractChange;
+
+/**
+ * 合同变更 Mapper 接口
+ *
+ * @author Chill
+ */
+public interface ContractChangeMapper extends BaseMapper {
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java
index 482a817e..296dfbfc 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java
@@ -10,6 +10,7 @@ import org.springblade.transport.pojo.dto.InvoiceApplicationStatusRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest;
import org.springblade.transport.pojo.entity.BillPayment;
+import org.springblade.transport.pojo.entity.ContractChange;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.InvoiceApplication;
@@ -19,6 +20,7 @@ import org.springblade.transport.pojo.entity.PreSettlement;
import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.pojo.entity.ProjectChange;
import org.springblade.transport.service.IBillPaymentService;
+import org.springblade.transport.service.IContractChangeService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.ICustomerArchiveService;
import org.springblade.transport.service.IFormalSettlementService;
@@ -224,6 +226,78 @@ public final class MkProcessBizHandlers {
}
}
+ @Component
+ @TenantIgnore
+ @RequiredArgsConstructor
+ public static class ContractChangeHandler implements IMkProcessBizHandler {
+ private final IContractChangeService service;
+ private final HcsImmediatePushSupport hcsImmediatePushSupport;
+
+ @Override
+ public String bizType() {
+ return "contract-change";
+ }
+
+ @Override
+ public Object publicDetail(Long id) {
+ return service.publicDetail(id);
+ }
+
+ @Override
+ public boolean syncFromProcessInfo(Long id, Object processInfo) {
+ ContractChange entity = service.getById(id);
+ if (entity == null) {
+ return false;
+ }
+ String node = MkProcessNodeHelper.currentNode(processInfo);
+ String processor = MkProcessNodeHelper.currentProcessor(processInfo);
+ if (Func.isEmpty(node) && Func.isEmpty(processor)) {
+ return false;
+ }
+ if (Func.isNotEmpty(node)) {
+ entity.setCurrentNode(node);
+ }
+ if (Func.isNotEmpty(processor)) {
+ entity.setCurrentProcessor(processor);
+ }
+ entity.setApprovalStatus("reviewing");
+ return service.updateById(entity);
+ }
+
+ @Override
+ public boolean approveFromProcess(Long id, String processorName) {
+ ContractChange entity = service.getById(id);
+ if (entity == null || "approved".equals(entity.getApprovalStatus())) {
+ return entity != null;
+ }
+ Long contractId = service.approve(id);
+ entity = service.getById(id);
+ if (entity != null) {
+ entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
+ service.updateById(entity);
+ }
+ if (contractId != null) {
+ hcsImmediatePushSupport.pushContractAfterCommit(contractId);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean rejectFromProcess(Long id, String processorName) {
+ ContractChange entity = service.getById(id);
+ if (entity == null || "rejected".equals(entity.getApprovalStatus())) {
+ return entity != null;
+ }
+ boolean rejected = service.reject(id);
+ entity = service.getById(id);
+ if (entity != null) {
+ entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
+ service.updateById(entity);
+ }
+ return rejected;
+ }
+ }
+
@Component
@TenantIgnore
@RequiredArgsConstructor
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractChangeService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractChangeService.java
new file mode 100644
index 00000000..8dab1de0
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractChangeService.java
@@ -0,0 +1,75 @@
+/**
+ * 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.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springblade.core.mp.base.BaseService;
+import org.springblade.transport.pojo.entity.ContractChange;
+import org.springblade.transport.pojo.vo.ContractChangeVO;
+
+/**
+ * 合同变更 服务类
+ *
+ * @author Chill
+ */
+public interface IContractChangeService extends BaseService {
+
+ IPage selectContractChangePage(IPage page, ContractChangeVO contractChange);
+
+ ContractChangeVO detail(Long id);
+
+ /**
+ * 公开详情(MK 回调/公开页)
+ */
+ ContractChangeVO publicDetail(Long id);
+
+ /**
+ * 基于正式合同创建变更草稿
+ */
+ ContractChangeVO createFromContract(Long contractId, String changeType);
+
+ boolean saveDraft(ContractChange contractChange);
+
+ boolean submit(ContractChange contractChange);
+
+ boolean submitApproval(Long id);
+
+ /**
+ * 审批通过:回写原合同并标记同步
+ *
+ * @return 原合同ID,供 Controller 推送合规
+ */
+ Long approve(Long id);
+
+ boolean reject(Long id);
+
+ boolean withdraw(Long id);
+
+ boolean removeDraft(String ids);
+
+ /**
+ * 批量标记同步状态(用于列表批量同步后回写)
+ */
+ boolean markSyncStatus(String ids, String syncStatus);
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java
index 33806995..d4f481b9 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java
@@ -25,6 +25,7 @@ package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.excel.ContractManageExcel;
+import org.springblade.transport.pojo.entity.ContractChange;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.vo.ContractManageVO;
@@ -57,6 +58,15 @@ public interface IContractManageService extends BaseService {
boolean withdraw(Long id);
boolean startChange(Long id, String changeContent, String changeReason);
boolean submitChange(ContractManage contractManage);
+
+ /**
+ * 将独立合同变更单回写到原合同(审批通过后调用,不改写原合同为 change_reviewing)
+ *
+ * @param change 合同变更单
+ * @return 是否成功
+ */
+ boolean applyChangeFromRecord(ContractChange change);
+
boolean updateAttachments(ContractManage contractManage);
boolean terminate(Long id, String reason);
boolean removeDraft(String ids);
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractChangeServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractChangeServiceImpl.java
new file mode 100644
index 00000000..a663c015
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractChangeServiceImpl.java
@@ -0,0 +1,461 @@
+/**
+ * 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.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.service.impl;
+
+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.tenant.annotation.TenantIgnore;
+import org.springblade.core.tool.jackson.JsonUtil;
+import org.springblade.core.tool.utils.BeanUtil;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractChangeMapper;
+import org.springblade.transport.pojo.entity.ContractChange;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.vo.ContractChangeVO;
+import org.springblade.transport.pojo.vo.ContractManageVO;
+import org.springblade.transport.service.IContractChangeService;
+import org.springblade.transport.service.IContractManageService;
+import org.springblade.transport.support.TransportBusinessSupport;
+import org.springblade.transport.wrapper.ContractChangeWrapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 合同变更 服务实现类
+ *
+ * @author Chill
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ContractChangeServiceImpl extends BaseServiceImpl implements IContractChangeService {
+
+ private static final String STATUS_DRAFT = "draft";
+ private static final String STATUS_REVIEWING = "reviewing";
+ private static final String STATUS_WITHDRAWN = "withdrawn";
+ private static final String STATUS_APPROVED = "approved";
+ private static final String STATUS_REJECTED = "rejected";
+ private static final String STAGE_FORMAL = "formal";
+ private static final String CHANGE_TYPE_INFO = "合同信息变更";
+ private static final String CHANGE_TYPE_SETTLEMENT = "结算规则变更";
+ private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ private final IContractManageService contractManageService;
+
+ @Override
+ public IPage selectContractChangePage(IPage page, ContractChangeVO contractChange) {
+ IPage entityPage = page(page, buildQuery(contractChange).orderByDesc(ContractChange::getCreateTime));
+ IPage voPage = ContractChangeWrapper.build().pageVO(entityPage);
+ voPage.getRecords().forEach(this::fillReadonly);
+ return voPage;
+ }
+
+ @Override
+ public ContractChangeVO detail(Long id) {
+ ContractChangeVO contractChangeVO = ContractChangeWrapper.build().entityVO(loadExists(id));
+ fillReadonly(contractChangeVO);
+ return contractChangeVO;
+ }
+
+ @Override
+ @TenantIgnore
+ public ContractChangeVO publicDetail(Long id) {
+ ContractChangeVO contractChangeVO = ContractChangeWrapper.build().entityVO(loadExists(id));
+ contractChangeVO.setReadonly(Boolean.TRUE);
+ return contractChangeVO;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public ContractChangeVO createFromContract(Long contractId, String changeType) {
+ ContractManageVO contractManage = contractManageService.detail(contractId);
+ validateContractChangeable(contractManage);
+ ContractChange reviewing = findLatestByStatuses(contractId, List.of(STATUS_REVIEWING));
+ if (reviewing != null) {
+ throw new ServiceException("该合同已有审批中的变更单,请先完成后再发起");
+ }
+ ContractChange unfinished = findLatestByStatuses(contractId, List.of(STATUS_DRAFT, STATUS_WITHDRAWN, STATUS_REJECTED));
+ if (unfinished != null) {
+ // 已有未完结变更单时复用,避免重复建单
+ return detail(unfinished.getId());
+ }
+ String resolvedType = Func.isBlank(changeType) ? CHANGE_TYPE_INFO : changeType.trim();
+ if (!List.of(CHANGE_TYPE_INFO, CHANGE_TYPE_SETTLEMENT).contains(resolvedType)) {
+ throw new ServiceException("请选择变更类型");
+ }
+ ContractChange contractChange = copyFromContract(contractManage);
+ contractChange.setId(null);
+ contractChange.setContractId(contractManage.getId());
+ contractChange.setChangeNo(nextChangeNo());
+ contractChange.setChangeType(resolvedType);
+ contractChange.setChangeDate(LocalDate.now());
+ contractChange.setChangeContent(null);
+ contractChange.setChangeReason(null);
+ contractChange.setChangeAttachmentsJson(null);
+ contractChange.setBeforeDataJson(JsonUtil.toJson(buildSnapshot(contractManage)));
+ contractChange.setApprovalStatus(STATUS_DRAFT);
+ contractChange.setCurrentNode("草稿");
+ contractChange.setCurrentProcessor(AuthUtil.getUserName());
+ contractChange.setApprovedTime(null);
+ contractChange.setSyncStatus(null);
+ save(contractChange);
+ return detail(contractChange.getId());
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean saveDraft(ContractChange contractChange) {
+ ContractChange oldRecord = loadEditable(contractChange.getId());
+ prepareEditableFields(oldRecord, contractChange);
+ validateDraft(oldRecord);
+ if (Func.isEmpty(oldRecord.getApprovalStatus()) || STATUS_REVIEWING.equals(oldRecord.getApprovalStatus())) {
+ oldRecord.setApprovalStatus(STATUS_DRAFT);
+ }
+ if (List.of(STATUS_WITHDRAWN, STATUS_REJECTED).contains(oldRecord.getApprovalStatus())) {
+ // 驳回/撤回后编辑仍保持原状态,提交时再进入审批中
+ } else {
+ oldRecord.setApprovalStatus(STATUS_DRAFT);
+ oldRecord.setCurrentNode("草稿");
+ }
+ return updateById(oldRecord);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean submit(ContractChange contractChange) {
+ ContractChange oldRecord = loadEditable(contractChange.getId());
+ prepareEditableFields(oldRecord, contractChange);
+ validateSubmit(oldRecord);
+ oldRecord.setApprovalStatus(STATUS_REVIEWING);
+ oldRecord.setCurrentNode(resolveApprovalNode(oldRecord));
+ oldRecord.setCurrentProcessor("待处理");
+ oldRecord.setApprovedTime(null);
+ return updateById(oldRecord);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean submitApproval(Long id) {
+ ContractChange contractChange = loadEditable(id);
+ validateSubmit(contractChange);
+ contractChange.setApprovalStatus(STATUS_REVIEWING);
+ contractChange.setCurrentNode(resolveApprovalNode(contractChange));
+ contractChange.setCurrentProcessor("待处理");
+ contractChange.setApprovedTime(null);
+ return updateById(contractChange);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long approve(Long id) {
+ ContractChange contractChange = loadReviewing(id);
+ contractChange.setApprovalStatus(STATUS_APPROVED);
+ contractChange.setCurrentNode("审批通过");
+ String processor = TransportBusinessSupport.trimToNull(AuthUtil.getUserName());
+ contractChange.setCurrentProcessor(processor == null ? "系统" : processor);
+ contractChange.setApprovedTime(LocalDateTime.now());
+ contractChange.setSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ updateById(contractChange);
+ contractManageService.applyChangeFromRecord(contractChange);
+ return contractChange.getContractId();
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean reject(Long id) {
+ ContractChange contractChange = loadReviewing(id);
+ contractChange.setApprovalStatus(STATUS_REJECTED);
+ contractChange.setCurrentNode("审核不通过");
+ String processor = TransportBusinessSupport.trimToNull(AuthUtil.getUserName());
+ contractChange.setCurrentProcessor(processor == null ? "系统" : processor);
+ return updateById(contractChange);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean withdraw(Long id) {
+ ContractChange contractChange = loadReviewing(id);
+ contractChange.setApprovalStatus(STATUS_WITHDRAWN);
+ contractChange.setCurrentNode("已撤回");
+ contractChange.setCurrentProcessor(AuthUtil.getUserName());
+ return updateById(contractChange);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean removeDraft(String ids) {
+ List idList = Func.toLongList(ids);
+ if (Func.isEmpty(idList)) {
+ throw new ServiceException("请选择需要删除的数据");
+ }
+ for (ContractChange contractChange : listByIds(idList)) {
+ if (!List.of(STATUS_DRAFT, STATUS_WITHDRAWN, STATUS_REJECTED).contains(contractChange.getApprovalStatus())) {
+ throw new ServiceException("仅草稿、已撤回、已驳回的合同变更允许删除");
+ }
+ }
+ return deleteLogic(idList);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean markSyncStatus(String ids, String syncStatus) {
+ List idList = Func.toLongList(ids);
+ if (Func.isEmpty(idList)) {
+ throw new ServiceException("请选择需要同步的数据");
+ }
+ if (!List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_SYNCED, HcsSyncConstant.HCS_STATUS_FAILED)
+ .contains(syncStatus)) {
+ throw new ServiceException("同步状态不合法");
+ }
+ for (ContractChange contractChange : listByIds(idList)) {
+ if (!Objects.equals(contractChange.getApprovalStatus(), STATUS_APPROVED)) {
+ throw new ServiceException("仅审批通过的合同变更允许更新同步状态");
+ }
+ contractChange.setSyncStatus(syncStatus);
+ updateById(contractChange);
+ }
+ return true;
+ }
+
+ private LambdaQueryWrapper buildQuery(ContractChangeVO contractChange) {
+ TransportBusinessSupport.validateAllDept(contractChange.getAllDept(), "合同变更");
+ LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(ContractChange::getIsDeleted, 0);
+ if (TransportBusinessSupport.isAdministrator()) {
+ if (Objects.equals(contractChange.getAllDept(), 1) && Func.isNotEmpty(contractChange.getOrganizationId())) {
+ queryWrapper.eq(ContractChange::getOrganizationId, contractChange.getOrganizationId());
+ }
+ } else if (!Objects.equals(contractChange.getAllDept(), 1)) {
+ Long currentDeptId = TransportBusinessSupport.currentDeptId("合同变更");
+ Long currentUserId = AuthUtil.getUserId();
+ queryWrapper.and(wrapper -> wrapper.eq(ContractChange::getOrganizationId, currentDeptId)
+ .or().eq(ContractChange::getCreateUser, currentUserId)
+ .or().eq(ContractChange::getHandlerUserId, currentUserId));
+ } else if (Func.isNotEmpty(contractChange.getOrganizationId())) {
+ queryWrapper.eq(ContractChange::getOrganizationId, contractChange.getOrganizationId());
+ }
+ queryWrapper.eq(Func.isNotEmpty(contractChange.getProjectId()), ContractChange::getProjectId, contractChange.getProjectId())
+ .like(Func.isNotEmpty(contractChange.getContractNo()), ContractChange::getContractNo, contractChange.getContractNo())
+ .like(Func.isNotEmpty(contractChange.getContractName()), ContractChange::getContractName, contractChange.getContractName())
+ .like(Func.isNotEmpty(contractChange.getProjectName()) && Func.isEmpty(contractChange.getProjectId()),
+ ContractChange::getProjectName, contractChange.getProjectName())
+ .like(Func.isNotEmpty(contractChange.getOrganizationName()), ContractChange::getOrganizationName, contractChange.getOrganizationName())
+ .eq(Func.isNotEmpty(contractChange.getContractCategory()), ContractChange::getContractCategory, contractChange.getContractCategory())
+ .eq(Func.isNotEmpty(contractChange.getSignType()), ContractChange::getSignType, contractChange.getSignType())
+ .eq(Func.isNotEmpty(contractChange.getApprovalStatus()), ContractChange::getApprovalStatus, contractChange.getApprovalStatus())
+ .eq(Func.isNotEmpty(contractChange.getChangeType()), ContractChange::getChangeType, contractChange.getChangeType())
+ .eq(Func.isNotEmpty(contractChange.getContractId()), ContractChange::getContractId, contractChange.getContractId())
+ .like(Func.isNotEmpty(contractChange.getChangeNo()), ContractChange::getChangeNo, contractChange.getChangeNo());
+ return queryWrapper;
+ }
+
+ private ContractChange copyFromContract(ContractManage contractManage) {
+ ContractChange contractChange = Objects.requireNonNull(BeanUtil.copyProperties(contractManage, ContractChange.class));
+ contractChange.setId(null);
+ contractChange.setCreateUser(null);
+ contractChange.setCreateDept(null);
+ contractChange.setCreateTime(null);
+ contractChange.setUpdateUser(null);
+ contractChange.setUpdateTime(null);
+ contractChange.setStatus(1);
+ contractChange.setIsDeleted(0);
+ return contractChange;
+ }
+
+ private void prepareEditableFields(ContractChange target, ContractChange source) {
+ target.setChangeType(TransportBusinessSupport.trimToNull(source.getChangeType()));
+ target.setChangeReason(TransportBusinessSupport.trimToNull(source.getChangeReason()));
+ target.setChangeContent(TransportBusinessSupport.trimToNull(source.getChangeContent()));
+ target.setChangeAttachmentsJson(TransportBusinessSupport.trimToNull(source.getChangeAttachmentsJson()));
+ target.setSignType(source.getSignType());
+ target.setContractNo(source.getContractNo());
+ target.setContractName(source.getContractName());
+ target.setPartyB(source.getPartyB());
+ target.setStartDate(source.getStartDate());
+ target.setEndDate(source.getEndDate());
+ target.setContractFormat(source.getContractFormat());
+ target.setSettlementMode(source.getSettlementMode());
+ target.setLegalSealFlag(source.getLegalSealFlag());
+ target.setCopyCount(source.getCopyCount());
+ target.setSettlementCurrency(source.getSettlementCurrency());
+ target.setInvoiceCycle(source.getInvoiceCycle());
+ target.setPaymentDays(source.getPaymentDays());
+ target.setContractAmount(source.getContractAmount());
+ target.setTemplateFlag(source.getTemplateFlag());
+ target.setOriginalContractNo(source.getOriginalContractNo());
+ target.setElectronicSealFlag(source.getElectronicSealFlag());
+ target.setBillingEnabled(source.getBillingEnabled());
+ target.setFeeGenerationMode(source.getFeeGenerationMode());
+ target.setContractFileJson(source.getContractFileJson());
+ target.setAttachmentsJson(source.getAttachmentsJson());
+ target.setBillingPlanJson(source.getBillingPlanJson());
+ target.setSettlementRuleJson(source.getSettlementRuleJson());
+ target.setPreSettlementConfigJson(source.getPreSettlementConfigJson());
+ target.setFormalSettlementConfigJson(source.getFormalSettlementConfigJson());
+ target.setPaymentRatioJson(source.getPaymentRatioJson());
+ target.setReconciliationJson(source.getReconciliationJson());
+ target.setMainContent(source.getMainContent());
+ target.setRemark(source.getRemark());
+ }
+
+ private Map buildSnapshot(ContractManage contractManage) {
+ Map data = new LinkedHashMap<>();
+ data.put("contractNo", contractManage.getContractNo());
+ data.put("contractName", contractManage.getContractName());
+ data.put("signType", contractManage.getSignType());
+ data.put("partyB", contractManage.getPartyB());
+ data.put("startDate", contractManage.getStartDate());
+ data.put("endDate", contractManage.getEndDate());
+ data.put("contractFormat", contractManage.getContractFormat());
+ data.put("settlementMode", contractManage.getSettlementMode());
+ data.put("legalSealFlag", contractManage.getLegalSealFlag());
+ data.put("copyCount", contractManage.getCopyCount());
+ data.put("settlementCurrency", contractManage.getSettlementCurrency());
+ data.put("invoiceCycle", contractManage.getInvoiceCycle());
+ data.put("paymentDays", contractManage.getPaymentDays());
+ data.put("contractAmount", contractManage.getContractAmount());
+ data.put("templateFlag", contractManage.getTemplateFlag());
+ data.put("originalContractNo", contractManage.getOriginalContractNo());
+ data.put("electronicSealFlag", contractManage.getElectronicSealFlag());
+ data.put("mainContent", contractManage.getMainContent());
+ data.put("remark", contractManage.getRemark());
+ data.put("feeGenerationMode", contractManage.getFeeGenerationMode());
+ data.put("billingPlanJson", contractManage.getBillingPlanJson());
+ data.put("settlementRuleJson", contractManage.getSettlementRuleJson());
+ data.put("preSettlementConfigJson", contractManage.getPreSettlementConfigJson());
+ data.put("formalSettlementConfigJson", contractManage.getFormalSettlementConfigJson());
+ data.put("paymentRatioJson", contractManage.getPaymentRatioJson());
+ data.put("contractFileJson", contractManage.getContractFileJson());
+ data.put("attachmentsJson", contractManage.getAttachmentsJson());
+ return data;
+ }
+
+ private void validateContractChangeable(ContractManage contractManage) {
+ boolean approved = List.of("approved", "change_approved").contains(contractManage.getApprovalStatus());
+ if (!approved || !Objects.equals(contractManage.getContractStage(), STAGE_FORMAL)) {
+ throw new ServiceException("仅正式合同且审批通过允许发起变更");
+ }
+ }
+
+ private ContractChange findLatestByStatuses(Long contractId, List statuses) {
+ return getOne(Wrappers.lambdaQuery()
+ .eq(ContractChange::getIsDeleted, 0)
+ .eq(ContractChange::getContractId, contractId)
+ .in(ContractChange::getApprovalStatus, statuses)
+ .orderByDesc(ContractChange::getCreateTime)
+ .last("limit 1"), false);
+ }
+
+ private void validateDraft(ContractChange contractChange) {
+ TransportBusinessSupport.validateRequired(contractChange.getChangeType(), "请选择变更类型");
+ if (!List.of(CHANGE_TYPE_INFO, CHANGE_TYPE_SETTLEMENT).contains(contractChange.getChangeType())) {
+ throw new ServiceException("请选择变更类型");
+ }
+ TransportBusinessSupport.validateLength(contractChange.getChangeReason(), 500, "变更原因不能超过500个字");
+ TransportBusinessSupport.validateLength(contractChange.getChangeContent(), 2000, "变更内容不能超过2000个字");
+ }
+
+ private void validateSubmit(ContractChange contractChange) {
+ validateDraft(contractChange);
+ TransportBusinessSupport.validateRequired(contractChange.getChangeReason(), "请输入变更原因");
+ if (Objects.equals(contractChange.getChangeType(), CHANGE_TYPE_SETTLEMENT)) {
+ return;
+ }
+ TransportBusinessSupport.validateRequired(contractChange.getContractName(), "请输入合同名称");
+ TransportBusinessSupport.validateRequired(contractChange.getSettlementCurrency(), "请选择结算币种");
+ if (contractChange.getContractAmount() != null && contractChange.getContractAmount().compareTo(BigDecimal.ZERO) < 0) {
+ throw new ServiceException("合同金额不能小于0");
+ }
+ }
+
+ private String resolveApprovalNode(ContractChange contractChange) {
+ return Func.isBlank(contractChange.getChangeType()) ? "合同变更审批" : contractChange.getChangeType() + "审批";
+ }
+
+ private synchronized String nextChangeNo() {
+ return "HTBG" + LocalDate.now().format(CODE_DATE) + String.format("%06d", count() + 1);
+ }
+
+ private ContractChange loadExists(Long id) {
+ if (Func.isEmpty(id)) {
+ throw new ServiceException("主键不能为空");
+ }
+ ContractChange contractChange = getById(id);
+ if (Func.isEmpty(contractChange) || Objects.equals(contractChange.getIsDeleted(), 1)) {
+ throw new ServiceException("合同变更不存在");
+ }
+ return contractChange;
+ }
+
+ private ContractChange loadEditable(Long id) {
+ ContractChange contractChange = loadExists(id);
+ if (!canCurrentUserOperate(contractChange)) {
+ throw new ServiceException("无权操作其他组织合同变更");
+ }
+ if (!List.of(STATUS_DRAFT, STATUS_WITHDRAWN, STATUS_REJECTED).contains(contractChange.getApprovalStatus())) {
+ throw new ServiceException("当前状态不允许编辑");
+ }
+ return contractChange;
+ }
+
+ private ContractChange loadReviewing(Long id) {
+ ContractChange contractChange = loadExists(id);
+ if (!Objects.equals(contractChange.getApprovalStatus(), STATUS_REVIEWING)) {
+ throw new ServiceException("仅审批中的合同变更允许该操作");
+ }
+ return contractChange;
+ }
+
+ private boolean canCurrentUserOperate(ContractChange contractChange) {
+ if (TransportBusinessSupport.isAdministrator()) {
+ return true;
+ }
+ Long currentUserId = AuthUtil.getUserId();
+ if (Objects.equals(contractChange.getCreateUser(), currentUserId)
+ || Objects.equals(contractChange.getHandlerUserId(), currentUserId)) {
+ return true;
+ }
+ Long currentDeptId = TransportBusinessSupport.currentDeptId("合同变更");
+ return currentDeptId != null && Objects.equals(contractChange.getOrganizationId(), currentDeptId);
+ }
+
+ private void fillReadonly(ContractChangeVO contractChangeVO) {
+ contractChangeVO.setReadonly(!canCurrentUserOperate(contractChangeVO));
+ }
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
index bb9d57d0..3d2ac007 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
@@ -41,6 +41,7 @@ import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.excel.ContractManageExcel;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.HcsContractExeStageMapper;
+import org.springblade.transport.pojo.entity.ContractChange;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.HcsContractExeStage;
import org.springblade.transport.pojo.vo.ContractManageVO;
@@ -341,6 +342,95 @@ public class ContractManageServiceImpl extends BaseServiceImpl beforeData = buildContractChangeSnapshot(source);
+ if (settlementRuleOnly) {
+ applySettlementRuleChange(source, request);
+ } else {
+ applyContractInfoChange(source, request);
+ }
+ Map afterData = buildContractChangeSnapshot(source);
+ retainChangedSnapshotFields(beforeData, afterData);
+ source.setChangeContent(TransportBusinessSupport.trimToNull(change.getChangeContent()));
+ source.setChangeReason(changeReason);
+ source.setChangeAttachmentsJson(TransportBusinessSupport.trimToNull(change.getChangeAttachmentsJson()));
+ appendChangeRecord(source, changeType, changeReason, STATUS_CHANGE_APPROVED, "变更审批通过", beforeData, afterData);
+ source.setAttachmentsJson(mergeAttachmentJson(source.getAttachmentsJson(), source.getChangeAttachmentsJson()));
+ source.setChangeAttachmentsJson(null);
+ source.setChangeContent(null);
+ source.setChangeReason(null);
+ source.setContractFileJson(markContractFilesApproved(source.getContractFileJson()));
+ refreshArchiveStatusOnApprove(source);
+ source.setApprovalStatus(STATUS_CHANGE_APPROVED);
+ source.setCurrentNode("变更审批通过");
+ String processor = TransportBusinessSupport.trimToNull(AuthUtil.getUserName());
+ source.setCurrentProcessor(processor == null ? "系统" : processor);
+ source.setApprovedTime(LocalDateTime.now());
+ source.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ contractExeStageService.createDefaultRowOnApprove(source);
+ hcsContractExeStageMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(HcsContractExeStage::getContractId, source.getId())
+ .isNull(HcsContractExeStage::getHcsStageFdId)
+ .set(HcsContractExeStage::getRowOp, "add"));
+ return updateById(source);
+ }
+
+ /**
+ * 将变更单可回写字段映射为临时 ContractManage,供 apply*Change 复用。
+ */
+ private ContractManage toContractManageRequest(ContractChange change) {
+ ContractManage request = new ContractManage();
+ request.setChangeType(change.getChangeType());
+ request.setChangeContent(change.getChangeContent());
+ request.setChangeReason(change.getChangeReason());
+ request.setChangeAttachmentsJson(change.getChangeAttachmentsJson());
+ request.setSignType(change.getSignType());
+ request.setContractNo(change.getContractNo());
+ request.setContractName(change.getContractName());
+ request.setPartyB(change.getPartyB());
+ request.setStartDate(change.getStartDate());
+ request.setEndDate(change.getEndDate());
+ request.setContractFormat(change.getContractFormat());
+ request.setSettlementMode(change.getSettlementMode());
+ request.setLegalSealFlag(change.getLegalSealFlag());
+ request.setCopyCount(change.getCopyCount());
+ request.setSettlementCurrency(change.getSettlementCurrency());
+ request.setInvoiceCycle(change.getInvoiceCycle());
+ request.setPaymentDays(change.getPaymentDays());
+ request.setContractAmount(change.getContractAmount());
+ request.setTemplateFlag(change.getTemplateFlag());
+ request.setOriginalContractNo(change.getOriginalContractNo());
+ request.setElectronicSealFlag(change.getElectronicSealFlag());
+ request.setRemark(change.getRemark());
+ request.setMainContent(change.getMainContent());
+ request.setBillingEnabled(change.getBillingEnabled());
+ request.setFeeGenerationMode(change.getFeeGenerationMode());
+ request.setBillingPlanJson(change.getBillingPlanJson());
+ request.setSettlementRuleJson(change.getSettlementRuleJson());
+ request.setPreSettlementConfigJson(change.getPreSettlementConfigJson());
+ request.setFormalSettlementConfigJson(change.getFormalSettlementConfigJson());
+ request.setPaymentRatioJson(change.getPaymentRatioJson());
+ request.setContractFileJson(change.getContractFileJson());
+ request.setAttachmentsJson(change.getAttachmentsJson());
+ return request;
+ }
+
/**
* 解析并校验合同变更类型,兼容历史未传类型的请求。
*/
@@ -358,7 +448,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl
+ * 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.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.wrapper;
+
+import org.springblade.core.mp.support.BaseEntityWrapper;
+import org.springblade.core.tool.utils.BeanUtil;
+import org.springblade.system.cache.UserCache;
+import org.springblade.transport.pojo.entity.ContractChange;
+import org.springblade.transport.pojo.vo.ContractChangeVO;
+
+import java.util.Objects;
+
+/**
+ * 合同变更包装类
+ *
+ * @author Chill
+ */
+public class ContractChangeWrapper extends BaseEntityWrapper {
+
+ public static ContractChangeWrapper build() {
+ return new ContractChangeWrapper();
+ }
+
+ @Override
+ public ContractChangeVO entityVO(ContractChange contractChange) {
+ ContractChangeVO contractChangeVO = Objects.requireNonNull(BeanUtil.copyProperties(contractChange, ContractChangeVO.class));
+ contractChangeVO.setCreateUserName(UserCache.getUserRealName(contractChange.getCreateUser()));
+ contractChangeVO.setUpdateUserName(UserCache.getUserRealName(contractChange.getUpdateUser()));
+ contractChangeVO.setApprovalStatusName(statusName(contractChange.getApprovalStatus()));
+ contractChangeVO.setSyncStatusName(syncStatusName(contractChange.getSyncStatus()));
+ return contractChangeVO;
+ }
+
+ private String statusName(String status) {
+ if (status == null) {
+ return "未知";
+ }
+ return switch (status) {
+ case "draft" -> "草稿";
+ case "reviewing" -> "审批中";
+ case "withdrawn" -> "已撤回";
+ case "approved" -> "审批通过";
+ case "rejected" -> "已驳回";
+ default -> "未知";
+ };
+ }
+
+ private String syncStatusName(String syncStatus) {
+ if (syncStatus == null || syncStatus.isBlank()) {
+ return "";
+ }
+ return switch (syncStatus) {
+ case "unsynced" -> "待推送";
+ case "synced" -> "已推送";
+ case "failed" -> "推送失败";
+ default -> syncStatus;
+ };
+ }
+
+}
diff --git a/doc/sql/transport/blade_contract_change_20261027.sql b/doc/sql/transport/blade_contract_change_20261027.sql
new file mode 100644
index 00000000..d897655d
--- /dev/null
+++ b/doc/sql/transport/blade_contract_change_20261027.sql
@@ -0,0 +1,95 @@
+-- 合同变更独立单据 + 菜单
+-- 审批通过后再回写 blade_contract_manage
+
+CREATE TABLE IF NOT EXISTS `blade_contract_change` (
+ `id` bigint(20) NOT NULL COMMENT '主键',
+ `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户ID',
+ `create_user` bigint(20) DEFAULT NULL COMMENT '创建人',
+ `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_user` bigint(20) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `status` int(11) DEFAULT '1' COMMENT '状态',
+ `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除',
+ `contract_id` bigint(20) NOT NULL COMMENT '原合同ID',
+ `change_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '变更编号',
+ `change_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '变更类型',
+ `change_date` date DEFAULT NULL COMMENT '变更日期',
+ `change_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '变更内容',
+ `change_reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '变更原因',
+ `change_attachments_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '变更附件JSON',
+ `before_data_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '变更前快照JSON',
+ `contract_no` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同编号',
+ `contract_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同名称',
+ `project_id` bigint(20) DEFAULT NULL COMMENT '所属项目ID',
+ `project_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所属项目',
+ `organization_id` bigint(20) DEFAULT NULL COMMENT '合同负责部门ID',
+ `organization_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同负责部门',
+ `contract_category` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同类别',
+ `sign_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '签约类型',
+ `party_a` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '甲方',
+ `party_b` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '乙方',
+ `start_date` date DEFAULT NULL COMMENT '开始日期',
+ `end_date` date DEFAULT NULL COMMENT '结束日期',
+ `temporary_start_date` date DEFAULT NULL COMMENT '临时效力起',
+ `temporary_end_date` date DEFAULT NULL COMMENT '临时效力止',
+ `handler_user_id` bigint(20) DEFAULT NULL COMMENT '经办人ID',
+ `handler_user_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '经办人',
+ `sign_date` date DEFAULT NULL COMMENT '签订日期',
+ `apply_date` date DEFAULT NULL COMMENT '申请日期',
+ `settlement_mode` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '结算方式',
+ `contract_format` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同格式',
+ `legal_seal_flag` int(11) DEFAULT NULL COMMENT '是否需要加盖法人章',
+ `copy_count` int(11) DEFAULT NULL COMMENT '一式份数',
+ `settlement_currency` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '结算币种',
+ `invoice_cycle` int(11) DEFAULT NULL COMMENT '开票周期(天)',
+ `payment_days` int(11) DEFAULT NULL COMMENT '回款账期(天)',
+ `contract_amount` decimal(18,2) DEFAULT NULL COMMENT '合同金额',
+ `template_flag` int(11) DEFAULT NULL COMMENT '是否范本',
+ `original_contract_no` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '原件合同编号',
+ `electronic_seal_flag` int(11) DEFAULT NULL COMMENT '是否电子章',
+ `billing_enabled` int(11) DEFAULT NULL COMMENT '计费信息开关',
+ `fee_generation_mode` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '费用生成模式',
+ `contract_file_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '合同主文件JSON',
+ `attachments_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '其它附件JSON',
+ `billing_plan_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '计费方案JSON',
+ `settlement_rule_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '结算生成规则JSON',
+ `pre_settlement_config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '预结算配置JSON',
+ `formal_settlement_config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '正式结算配置JSON',
+ `payment_ratio_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '付款比例设置JSON',
+ `reconciliation_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '对账配置JSON',
+ `main_content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同主要内容',
+ `remark` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '其他事项说明',
+ `hcs_risk_level` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合规客商风险等级(展示用)',
+ `hcs_black_status` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合规客商黑名单状态(展示用)',
+ `approval_status` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审批状态',
+ `current_node` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '当前节点',
+ `current_processor` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '当前处理人',
+ `approved_time` datetime DEFAULT NULL COMMENT '审核通过时间',
+ `sync_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '同步状态 unsynced/synced/failed',
+ PRIMARY KEY (`id`) USING BTREE,
+ KEY `idx_contract_change_contract` (`contract_id`) USING BTREE,
+ KEY `idx_contract_change_status` (`approval_status`) USING BTREE,
+ KEY `idx_contract_change_type` (`change_type`) USING BTREE,
+ KEY `idx_contract_change_create_time` (`create_time`) USING BTREE,
+ KEY `idx_contract_change_no` (`change_no`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='合同变更';
+
+-- 菜单:合同管理下增加「合同变更」
+INSERT IGNORE INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES
+(2110000000000000200, 2110000000000000000, 'contract_change', '合同变更', 'contract_change', '/business/contract-change', 'iconfont icon-caidanguanli', 20, 1, 0, 1, NULL, '', 0),
+(2110000000000000201, 2110000000000000200, 'contract_change_view', '查看', 'contract_change_view', '', '', 1, 2, 0, 1, NULL, '', 0),
+(2110000000000000202, 2110000000000000200, 'contract_change_add', '新增', 'contract_change_add', '', '', 2, 2, 0, 1, NULL, '', 0),
+(2110000000000000203, 2110000000000000200, 'contract_change_edit', '编辑', 'contract_change_edit', '', '', 3, 2, 0, 1, NULL, '', 0),
+(2110000000000000204, 2110000000000000200, 'contract_change_delete', '删除', 'contract_change_delete', '', '', 4, 2, 0, 1, NULL, '', 0),
+(2110000000000000205, 2110000000000000200, 'contract_change_submit', '提交', 'contract_change_submit', '', '', 5, 2, 0, 1, NULL, '', 0),
+(2110000000000000206, 2110000000000000200, 'contract_change_withdraw', '撤回', 'contract_change_withdraw', '', '', 6, 2, 0, 1, NULL, '', 0),
+(2110000000000000207, 2110000000000000200, 'contract_change_approve', '审批通过', 'contract_change_approve', '', '', 7, 2, 0, 1, NULL, '', 0),
+(2110000000000000208, 2110000000000000200, 'contract_change_reject', '审批驳回', 'contract_change_reject', '', '', 8, 2, 0, 1, NULL, '', 0);
+
+-- 业务字典 mk_template:提交合同变更审核流(dict_key 需按实际 MK 模板编码配置)
+INSERT IGNORE INTO `blade_dict_biz` (`id`, `tenant_id`, `parent_id`, `code`, `dict_key`, `dict_value`, `sort`, `remark`, `is_sealed`, `status`, `is_deleted`)
+SELECT 2110000000000000299, '000000', id, 'mk_template', 'CONTRACT_CHANGE_TEMPLATE', '提交合同变更审核流', 99, '合同变更MK模板编码,请替换为真实 templateCode', 0, 1, 0
+FROM `blade_dict_biz`
+WHERE `code` = 'mk_template' AND `parent_id` = 0 AND `is_deleted` = 0
+LIMIT 1;