SpringBoot+Vue3+Flowable 实现合同管理全生命周期:起草·审批·签署·履约·回款台账闭环设计
📦 源码1:ruoyi-office-vben |📦 源码2:ruoyi-office |📦 源码3:ruoyi-office
合同管理在企业里地位特殊——它既是销售/采购的成果落地,又是法务/财务/履约/审计的台账依据。起草态和资产态是两种完全不同的对象:起草态是"在编辑、可改、可拒绝"的草稿;资产态是"已成立、要履行、要存档"的法律凭证。绝大多数合同系统把两者混在一张表里,结果"草稿被人当作生效合同用、生效合同被人当作草稿改",事故频发。RuoYi Office 用 contract_info(起草单)+ contract_ledger(台账)双表分离 + 9 状态生命周期 + 三条 BPM 流程,把合同从草稿到归档全程串成一条不可逾越的闭环。本文完整拆解其设计思路与核心源码。 
▲ 合同管理全景:9 状态生命周期、起草单 vs 台账双表分离、三条 BPM 流程经 FlowBillService 回调驱动、收付款计划 + 履约登记 + 到期提醒闭环
引言:合同管理的 5 个老大难
不管是销售合同、采购合同、租赁合同还是服务合同,本质都要回答这五个问题:
问题一:起草和生效混为一谈。常见做法是一张 contract 表加一个 status 字段,草稿、审批中、生效、终止全塞里面。结果导出"现有合同台账"时还要 WHERE status >= 2 过滤——草稿和台账逻辑搅在一起,谁查都难。
问题二:审批通过后改不了又必须改。合同审批通过 = 法律生效 = 不能修改。但现实是经常出现"忘填一个字段、对方公司名拼错、金额漏一位"——必须重新走"合同变更"流程,但又不能让审批中的变更影响主合同台账。
问题三:签署环节有没有?。有些公司纯电子合同(如 SaaS 订阅),审批通过就生效;有些公司必须线下盖章签字回传扫描件才生效。这两种业务模式必须能同套系统兼容。
问题四:履约不只是收款。一份合同的履约可能包括:交付货物、开发票、收款、付款、验收、提供服务……每个动作都要登记台账,最后汇总成"已履约金额 / 履约进度"。
问题五:到期了没人盯。合同 12 月 31 日到期,到时候没人续签——客户跑了、租赁合同自动到期续约……需要到期前 N 天自动提醒相关人。

RuoYi Office 用一个独立的 yudao-module-contract 模块解决这五个问题。本文拆解。
一、生命周期:9 个状态、3 条流程
1.1 状态机一图
草稿0 ──提交审批──▶ 审批中1 ──通过──▶ 已起草2
│ │
└──拒绝──┐ ├─signEnabled=true──▶ 签署中3 ──签署通过──▶ 已生效4
▼ └─signEnabled=false─────────────────────▶ 已生效4
(回草稿0)
│
首次履约
▼
履约中5
│
标记完成
▼
已完成6
│
归档
▼
已归档8
任意阶段 ──终止──▶ 已终止7
1.2 状态枚举
public enum ContractStatusEnum {
DRAFT(0, "草稿"),
APPROVING(1, "审批中"),
APPROVED(2, "已起草"),
SIGNING(3, "签署中"),
EFFECTIVE(4, "已生效"),
PERFORMING(5, "履约中"),
COMPLETED(6, "已完成"),
TERMINATED(7, "已终止"),
ARCHIVED(8, "已归档");
private final Integer value;
private final String name;
}
注意 status 的设计哲学:前 2 个状态(0、1)是"起草态",2 之后都是"资产态"——这条边界正是双表分离的依据。
1.3 三条 BPM 流程
public enum ContractBillTypeEnum implements BillTypeEnum {
CONTRACT_APPROVE("301", "合同审批", "contract_approve"),
CONTRACT_CHANGE("302", "合同变更审批", "contract_change"),
CONTRACT_SIGN("303", "合同签署确认", "contract_sign");
}
| contract_approve | 起草单提交 | 生成台账(status=2 已起草) |
| contract_change | 已生效合同需要修改 | 把变更回写到主合同 |
| contract_sign | 已起草合同需要签署 | 台账状态推进到"已生效" |
二、双表分离:起草单 vs 台账
2.1 设计思想
┌─────────────────────────┐ ┌─────────────────────────┐
│ contract_info(起草单)│ │ contract_ledger(台账) │
│ │ │ │
│ state: 0草稿 / 1审批中 │ 通过后 │ state: 2 已起草 ─▶ 8归档│
│ / 2已起草 │ 镜像复制│ │
│ │ ───────▶│ ledger.id │
│ 承载:草稿编辑、提交 │ 同 ID │ = draft.id │
│ BPM、变更 │ │ = draftContractId │
└─────────────────────────┘ └─────────────────────────┘
起草单只关心"能不能签" 台账只关心"已经签了,怎么管"
关键规则:
- 起草单 contract_info 的 ID 和台账 contract_ledger 的 ID 完全相同(不重新生成)
- 审批通过后通过 BeanUtils.toBean 把起草单镜像复制到台账
- 起草单的 status 最多到 2(已起草),后续状态推进只发生在台账上
- 草稿期改起草单不影响台账;变更流程提交"变更单",审批通过后回写主合同(起草单)
2.2 起草单 DO
@TableName("contract_info")
public class ContractInfoDO extends BaseDO {
@TableId
private Long id;
private String billCode; // 单据编号(Redis 严格序号)
private String contractCode; // 合同编号(HT+日期+序号)
private String contractName;
private Long partyAId; // 甲方
private Long partyBId; // 乙方
private BigDecimal totalAmount; // 合同总额
private LocalDate startDate;
private LocalDate endDate;
private Integer status; // 0草稿 / 1审批中 / 2已起草
private String processInstanceId;
private Integer processStatus;
private String contractFileUrls; // 起草稿
private String signedFileUrl; // 签署后文件
private LocalDate effectiveDate;
// …
private BigDecimal performanceAmount; // 累计履约金额
private Integer performanceStatus; // 履约进度状态
}
2.3 台账 DO
@TableName("contract_ledger")
public class ContractLedgerDO extends BaseDO {
@TableId
private Long id; // = draft.id
private Long draftContractId; // = draft.id
private String contractCode;
private String contractName;
// … 与起草单镜像的业务字段 …
private BigDecimal totalAmount;
private BigDecimal performanceAmount;
private Integer performanceStatus;
/** 状态:2 已起草 / 3 签署中 / 4 已生效 / 5 履约中 / 6 已完成 / 7 已终止 / 8 已归档 */
private Integer status;
}
2.4 审批通过 → 生成台账
@Transactional(rollbackFor = Exception.class)
public ContractLedgerDO upsertFromDraft(ContractInfoDO draft, Integer status) {
ContractLedgerDO ledger = BeanUtils.toBean(draft, ContractLedgerDO.class);
ledger.setId(draft.getId());
ledger.setDraftContractId(draft.getId());
ledger.setStatus(status);
ContractLedgerDO existing = contractLedgerMapper.selectById(ledger.getId());
if (existing == null) {
contractLedgerMapper.insert(ledger);
} else {
contractLedgerMapper.updateById(ledger);
}
return ledger;
}
双表分离的价值:
| 查"现有合同" | WHERE status >= 2(容易写错) | SELECT * FROM contract_ledger(语义清晰) |
| 草稿大量但生效少 | 全部查 contract 表,影响性能 | 台账表数据少且稳定 |
| 起草修改频繁 | 与生效查询互相影响 | 互相隔离 |
| 法律合规 | 起草/资产难分离 | 台账即资产,可独立做审计 |
三、起草审批 → 生成台账
3.1 提交审批
public void submitContract(Long id) {
ContractInfoDO contractInfo = validateContractInfoExists(id);
if (!ContractStatusEnum.DRAFT.getValue().equals(contractInfo.getStatus())) {
throw exception(CONTRACT_SUBMIT_FAIL_NOT_DRAFT);
}
Map<String, Object> processInstanceVariables = ...; // 流程变量(金额/期限分支用)
String processInstanceId = processInstanceApi.submitProcessInstance(
Long.valueOf(contractInfo.getCreator()),
new BpmProcessInstanceCreateReqDTO()
.setProcessDefinitionKey(ContractBillTypeEnum.CONTRACT_APPROVE.getProcessDefinitionKey())
.setVariables(processInstanceVariables)
.setStartUserSelectAssignees(saveReqVO.getStartUserSelectAssignees())
.setBusinessKey(String.valueOf(id))
).getCheckedData();
// 更新状态为 审批中
contractInfoMapper.updateById(new ContractInfoDO().setId(id)
.setStatus(ContractStatusEnum.APPROVING.getValue())
.setProcessInstanceId(processInstanceId));
}
3.2 onProcessApproved 自动建台账
审批通过后,Flowable 引擎触发回调 → ContractInfoServiceImpl.onProcessApproved:
public void onProcessApproved(String businessKey) {
Long id = Long.parseLong(businessKey);
// 1. 起草单状态 → 已起草(2)
contractInfoMapper.updateById(new ContractInfoDO().setId(id)
.setStatus(ContractStatusEnum.APPROVED.getValue()));
// 2. 镜像生成台账,状态=已起草(2)
ContractInfoDO contractInfo = contractInfoMapper.selectById(id);
contractLedgerService.upsertFromDraft(contractInfo, ContractStatusEnum.APPROVED.getValue());
// 3. 根据签署配置决定下一步
ContractConfigDO config = contractConfigService.getConfig();
boolean signEnabled = config != null && Boolean.TRUE.equals(config.getSignEnabled());
if (signEnabled) {
// 自动生成签署记录草稿(用户后续手动提交签署流程)
ContractSignSaveReqVO signReq = ...;
contractSignService.createContractSign(signReq);
} else {
// 未启用签署 → 直接进入"已生效"
contractInfoMapper.updateById(new ContractInfoDO().setId(id)
.setStatus(ContractStatusEnum.EFFECTIVE.getValue())
.setEffectiveDate(LocalDate.now()));
contractLedgerService.markEffective(id, LocalDate.now(), null);
}
}
signEnabled 是一个全局开关——一句配置决定整个企业的合同是否走线下签署环节。
四、FlowBillService 工厂回调机制
4.1 统一的 BPM 回调框架
合同模块的三条流程、CRM 的两条流程、OA/HR 的所有审批,都走 RuoYi Office 的 FlowBillService 工厂回调机制。核心是一个抽象监听器 AbstractFlowLocalNotificationListener,监听 BPM 引擎发出的"流程状态变更事件",按 processDefinitionKey 路由到对应业务模块的 FlowBillService 实现:
private void invokeLifecycleHooks(BpmProcessInstanceStatusEvent message) {
String processDefinitionKey = message.getProcessDefinitionKey();
Integer status = message.getStatus();
String businessKey = message.getBusinessKey();
FlowBillService<T> flowBillService = getFlowBillServiceFactory().getServiceByProcessKey(processDefinitionKey);
Runnable hookAction;
if (BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(status)) {
hookAction = () -> flowBillService.onProcessApproved(businessKey);
} else if (BpmProcessInstanceStatusEnum.REJECT.getStatus().equals(status)) {
hookAction = () -> flowBillService.onProcessRejected(businessKey);
} else if (BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(status)) {
hookAction = () -> flowBillService.onProcessCancelled(businessKey);
}
hookAction.run();
}
4.2 合同模块的工厂
@Component
public class ContractFlowBillServiceFactory extends FlowBillServiceFactory<ContractBillTypeEnum> {
@Override
protected ContractBillTypeEnum[] getBillTypeValues() {
return ContractBillTypeEnum.values();
}
}
工厂启动时收集所有 FlowBillService 实现,按 getSupportedBillType() 注册到 Map。运行时按 processDefinitionKey 找到对应实现。
4.3 三个 Service 各自实现 onProcessApproved
| 合同审批 | ContractInfoServiceImpl | 生成台账 + 按 signEnabled 决定下一步 |
| 合同变更 | ContractChangeServiceImpl | 把变更内容回写主合同(含台账) |
| 合同签署 | ContractSignServiceImpl | 台账状态 → 已生效 |
签署通过的关键代码:
public void onProcessApproved(String businessKey) {
Long id = Long.parseLong(businessKey);
ContractSignDO signDO = validateContractSignExists(id);
// … 校验主合同 …
contractUpdate.setStatus(ContractStatusEnum.EFFECTIVE.getValue());
contractUpdate.setEffectiveDate(LocalDate.now());
contractInfoMapper.updateById(contractUpdate);
contractLedgerService.markEffective(signDO.getContractId(), LocalDate.now(), signedFileUrl);
}
单体/微服务双模式支持:本地用 ContractLocalNotificationListener 监听 Spring 事件;微服务用 ContractMqNotificationConsumer 消费 MQ 消息。Service 实现不变,只是触发方式不同。这是 RuoYi Office 单体/微服务无缝切换的关键设计。
五、收付款计划 + 履约登记
5.1 收付款计划
@TableName("contract_payment_plan")
public class ContractPaymentPlanDO extends BaseDO {
private Long id;
private Long contractId;
private Integer period; // 期次
private BigDecimal planAmount; // 计划金额
private LocalDate planDate; // 计划日期
private BigDecimal actualAmount; // 实际金额(履约登记回写)
private LocalDate actualDate;
private Integer status; // 0待收付 / 1已收付 / 2已逾期
private String remark;
}
public enum ContractPaymentPlanStatusEnum {
PENDING(0, "待收付"),
PAID(1, "已收付"),
OVERDUE(2, "已逾期");
}
5.2 逾期 XXL-Job
@Component
public class ContractPaymentOverdueJob {
@XxlJob("contractPaymentOverdueJob")
@TenantJob
public String execute() {
LocalDate today = LocalDate.now();
int updateCount = contractPaymentPlanMapper.updateStatusByPlanDateBefore(
ContractPaymentPlanStatusEnum.PENDING.getValue(),
ContractPaymentPlanStatusEnum.OVERDUE.getValue(),
today);
return String.format("收付款逾期检查完成,更新 %d 条(计划日期 < %s)", updateCount, today);
}
}
Mapper 一条 UPDATE 批量改状态:
default int updateStatusByPlanDateBefore(Integer fromStatus, Integer toStatus, LocalDate beforeDate) {
return update(null, new LambdaUpdateWrapper<ContractPaymentPlanDO>()
.set(ContractPaymentPlanDO::getStatus, toStatus)
.eq(ContractPaymentPlanDO::getStatus, fromStatus)
.isNotNull(ContractPaymentPlanDO::getPlanDate)
.lt(ContractPaymentPlanDO::getPlanDate, beforeDate));
}
@TenantJob 注解保证多租户下每个租户独立扫表,不会串数据。
5.3 履约登记 → 回写计划 + 汇总台账
履约登记是"实际发生了什么"的台账,可对应交付、验收、收款、付款等多种类型:
@TableName("contract_performance")
public class ContractPerformanceDO extends BaseDO {
private Long id;
private Long contractId;
private Long paymentPlanId; // 关联哪个计划(可选)
/** 1交付 2验收 3开票 4收款 5付款 6服务交付 99其他 */
private Integer performanceType;
private BigDecimal performanceAmount;
private LocalDateTime performanceDate;
private String performanceContent;
}
创建履约的核心逻辑:
public Long createPerformance(ContractPerformanceSaveReqVO createReqVO) {
ContractLedgerDO contract = validateContractExists(createReqVO.getContractId());
assertContractCanPerform(contract); // 必须是已生效(4) / 履约中(5)
// …
contractPerformanceMapper.insert(row);
writebackPaymentPlan(createReqVO); // 回写计划
syncContractPerformanceStats(createReqVO.getContractId()); // 汇总台账
return row.getId();
}
回写计划:
private void writebackPaymentPlan(ContractPerformanceSaveReqVO reqVO) {
if (reqVO.getPaymentPlanId() == null) return;
ContractPaymentPlanDO update = new ContractPaymentPlanDO();
update.setId(reqVO.getPaymentPlanId());
update.setActualAmount(reqVO.getPerformanceAmount());
update.setActualDate(toLocalDate(reqVO.getPerformanceDate()));
update.setStatus(ContractPaymentPlanStatusEnum.PAID.getValue());
contractPaymentPlanMapper.updateById(update);
}
汇总台账履约额 + 推进状态:
private void syncContractPerformanceStats(Long contractId) {
BigDecimal sum = contractPerformanceMapper.selectSumAmountByContractId(contractId);
ContractLedgerDO contract = contractLedgerMapper.selectById(contractId);
update.setPerformanceAmount(sum);
update.setPerformanceStatus(status);
// 已生效(4) + 有履约 → 推进到 履约中(5)
if (sum != null && sum.compareTo(BigDecimal.ZERO) > 0
&& ContractStatusEnum.EFFECTIVE.getValue().equals(contract.getStatus())) {
update.setStatus(ContractStatusEnum.PERFORMING.getValue());
}
contractLedgerMapper.updateById(update);
}
亮点:状态推进是"事件驱动"的——首次履约自动从 EFFECTIVE 推进到 PERFORMING,不用人工切换。
六、到期提醒:ContractExpireNotifyJob
@Component
public class ContractExpireNotifyJob {
@XxlJob("contractExpireNotifyJob")
@TenantJob
public String execute() {
ContractConfigDO config = contractConfigService.getConfig();
if (config == null || !Boolean.TRUE.equals(config.getNotifyEnabled())) {
return "到期提醒未启用,跳过";
}
Integer notifyDays = config.getNotifyDays(); // 提前 N 天
LocalDate today = LocalDate.now();
LocalDate deadline = today.plusDays(notifyDays);
// 扫描台账:endDate 在 [today, today+N] 窗口内、状态为已起草/已生效/履约中
List<ContractLedgerDO> expiring = contractLedgerMapper.selectList(
new LambdaQueryWrapperX<ContractLedgerDO>()
.isNotNull(ContractLedgerDO::getEndDate)
.ge(ContractLedgerDO::getEndDate, today)
.le(ContractLedgerDO::getEndDate, deadline)
.in(ContractLedgerDO::getStatus, STATUS_APPROVED, STATUS_EFFECTIVE, STATUS_PERFORMING));
// … 用 NotifyMessageSendApi 发站内信,Redis 幂等去重(同一合同同一天只发一次)
return String.format("到期提醒完成,共 %d 份合同", expiring.size());
}
}
幂等设计:用 Redis SETNX contract:expire:notify:{contractId}:{date} 做去重,避免 Job 重试或多实例并发时重复发消息。
七、编号生成:billCode + contractCode
合同有两个编号字段:
| billCode | 单据编号(系统内严格唯一) | Redis INCR 严格递增 |
| contractCode | 合同编号(业务可读) | HT + 日期 + 4 位序号 |
7.1 billCode:Redis 严格序号
public class BillCodeUtils {
public String generate(String sysCode, String billTypeCode) {
String dateStr = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
String redisKey = REDIS_KEY_PREFIX + ":" + sysCode + ":" + billTypeCode + ":" + dateStr;
Long sequenceNo = stringRedisTemplate.opsForValue().increment(redisKey);
stringRedisTemplate.expire(redisKey, Duration.ofDays(2L));
return sysCode + billTypeCode + SEPARATOR + dateStr + String.format("%0" + SEQUENCE_LENGTH + "d", sequenceNo);
}
}
合同模块系统码 CT,类型码 301/302/303,示例:CT301-2026060300001、CT303-2026060300002。Redis INCR 严格递增,保证流水不重复。
7.2 contractCode:业务可读
public String generateContractCode() {
String dateStr = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String prefix = "HT" + dateStr;
int seq = (int) (Math.random() * 9000) + 1000;
return prefix + String.format("%04d", seq);
}
示例:HT202606031234。ContractConfigDO 已预留 codePrefix / codeDateFormat / codeSeqLength 字段,便于后续从配置表读取规则做严格序号化升级。
八、关键表与索引
| contract_info | id, status, processInstanceId | (status, end_date), (creator) |
| contract_ledger | id, status, endDate, performanceAmount | (status, end_date), (party_a_id) |
| contract_payment_plan | contractId, period, planDate, status | (contract_id, period), (plan_date, status) |
| contract_performance | contractId, paymentPlanId, performanceType | (contract_id, performance_date), (payment_plan_id) |
| contract_sign | contractId, status, processInstanceId | (contract_id) |
| contract_change | contractId, status, processInstanceId | (contract_id) |
| contract_config | signEnabled, notifyEnabled, notifyDays | 单租户单条配置 |
九、技术亮点总结
| 双表分离 | contract_info + contract_ledger 同 ID 镜像 | 起草态与资产态完全隔离 |
| 9 状态生命周期 | ContractStatusEnum 顺序状态机 | 草稿→归档全程可追溯 |
| 三条 BPM 流程 | contract_approve / change / sign | 节点条件可视化配置 |
| 统一回调框架 | FlowBillService 工厂 + 抽象监听器 | 单体/微服务无缝切换 |
| 签署可选 | signEnabled 全局配置 | 电子/线下两种业务模式兼容 |
| 履约事件驱动 | 首次履约自动推进到 PERFORMING | 状态不用人工切 |
| 履约回写 | 计划状态 + 台账履约额双向同步 | 数据一致性强 |
| 逾期定时扫描 | XXL-Job + 批量 UPDATE | 性能好,多租户隔离 |
| 到期提醒 | XXL-Job + Redis 幂等 | 不漏发也不重复 |
| 编号双轨 | billCode(Redis)+ contractCode(HT+日期) | 系统唯一 + 业务可读 |
十、快速体验
- 在线演示:http://ruoyioffice.com/web/(账号 admin / admin123)
- 操作路径:登录后台 → 顶部菜单 合同管理
- 推荐体验流程:
- 合同配置 打开"启用签署环节"、设置到期提醒天数(如 30 天)
- 合同起草 新建一份合同,填总额、起止日期、收付款计划(分 3 期)
- 提交审批 走 BPM 流程,审批通过后观察 合同台账 自动出现一条 “已起草” 记录
- 进 合同签署 提交签署流程,通过后台账状态变 “已生效”
- 履约登记 录入一笔收款,观察对应回款计划自动标 “已收付”、台账推进到 “履约中”
- 故意把某条计划的 planDate 改到昨天,等待或手动触发 contractPaymentOverdueJob,看状态变 “已逾期”
- 故意把合同 endDate 改到一周内,触发 contractExpireNotifyJob 看站内信
- 进 工作流 → 流程模型 查看 contract_approve / contract_change / contract_sign 三条流程的 BPMN 设计
源码仓库:
| 后端(GitCode) | https://gitcode.com/zhouzhongyan/ruoyi-office.git |
| 前端(GitCode) | https://gitcode.com/zhouzhongyan/ruoyi-office-vben.git |
| 后端(GitHub) | https://github.com/yuqing2026/ruoyi-office.git |
结语
合同管理本质是把一份"法律凭证"在企业内的全生命周期管起来。起草态和资产态分离是这套设计的灵魂——前者是"在编辑"的草稿,后者是"已成立"的资产,两者不能混。三条 BPM 流程 + FlowBillService 统一回调让审批/变更/签署三个环节有统一的扩展模型;履约登记 + 收付款计划构成闭环的"实际 vs 计划"对账机制;定时 Job + 幂等去重保证逾期与到期不漏发也不滥发。
这套架构同样适用于采购合同、租赁合同、服务合同、框架协议等任何"长生命周期 + 履约阶段 + 到期管理"的业务——只要替换字段含义和履约类型枚举即可。
你们公司的合同管理还在用 Excel/钉钉文档?欢迎评论区聊聊你的痛点和改造思路。
常见问题(FAQ)
合同模块在 RuoYi Office 的哪个目录?
独立模块 yudao-module-contract,路径 ruoyi-office/yudao-module-contract/,拆为 *-api(枚举/API)和 *-server(实现)两个子模块。注意 yudao-module-crm 里另有 CrmContractDO 是销售场景的简化合同,与本文的"合同管理模块"是两套独立实现。
为什么要做起草单 + 台账双表分离,不能用一张表加 status 字段吗?
可以但不推荐。单表方案在草稿大量、生效合同少的场景查询性能差;起草修改频繁时与生效查询互相影响;起草态和资产态在法律语义上是两类对象,分表更清晰、更安全、更易做审计。
signEnabled 配置可以按合同类型差异化吗?
当前是全局配置。如果业务真的需要差异化(如销售合同要签署、采购合同不要签署),可以在 ContractConfigDO 上扩 signEnabledByType JSON 字段,或者新增 contract_type_config 表按合同类型存配置,框架开放。
合同变更流程通过后会覆盖所有字段吗?
不会。ContractChangeDO 里只保留"变更项"(金额/期限/条款的旧值新值),审批通过时由 applyChangeToContract(change) 按变更项更新主合同对应字段,其他字段不动。历史变更记录保留在变更表里供审计。
合同到期提醒会不会重复发?
不会。ContractExpireNotifyJob 用 Redis SETNX contract:expire:notify:{contractId}:{date} 幂等去重,同一合同同一天只发一次。Job 失败重试或多租户/多实例并发都不会重复。
XXL-Job 没装能不能用?
逾期判断和到期提醒依赖 XXL-Job。如果暂未引入 XXL-Job,可用 Spring 的 @Scheduled 定时调用相同的 Mapper 方法替代,但失去任务分片、告警、人工触发等能力。生产环境推荐配置 XXL-Job。
💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:http://ruoyioffice.com/web/(账号 admin / admin123)
📦 源码仓库:GitCode | GitHub
💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!


