feat(m2): add contract change versioning with CHANGING state

This commit is contained in:
2026-05-25 01:32:20 +08:00
parent 88c4e22d36
commit 33773928c3
7 changed files with 243 additions and 2 deletions
@@ -3,6 +3,7 @@ package cn.craftlabs.platform.api.contracts;
import cn.craftlabs.platform.api.persistence.attachment.PlatformContractAttachment;
import cn.craftlabs.platform.api.persistence.attachment.PlatformContractAttachmentMapper;
import cn.craftlabs.platform.api.service.ContractService;
import cn.craftlabs.platform.api.service.ContractStatusTransitionService;
import cn.craftlabs.platform.api.web.dto.ContractCreateRequest;
import cn.craftlabs.platform.api.web.dto.ContractLineRequest;
import cn.craftlabs.platform.api.web.dto.ContractLineResponse;
@@ -41,10 +42,12 @@ public class ContractController {
private final ContractService contractService;
private final PlatformContractAttachmentMapper attachmentMapper;
private final ContractStatusTransitionService contractStatusTransitionService;
public ContractController(ContractService contractService, PlatformContractAttachmentMapper attachmentMapper) {
public ContractController(ContractService contractService, PlatformContractAttachmentMapper attachmentMapper, ContractStatusTransitionService contractStatusTransitionService) {
this.contractService = contractService;
this.attachmentMapper = attachmentMapper;
this.contractStatusTransitionService = contractStatusTransitionService;
}
@GetMapping
@@ -141,4 +144,16 @@ public class ContractController {
.orderByDesc(PlatformContractAttachment::getCreatedAt);
return ResponseEntity.ok(attachmentMapper.selectList(query));
}
@PostMapping("/{id}/changes")
public ResponseEntity<Void> initiateChange(@PathVariable Long id, @RequestBody Map<String, String> body) {
contractStatusTransitionService.initiateChange(id, body.getOrDefault("reason", ""));
return ResponseEntity.ok().build();
}
@PostMapping("/{id}/changes/complete")
public ResponseEntity<Void> completeChange(@PathVariable Long id) {
contractStatusTransitionService.completeChange(id);
return ResponseEntity.ok().build();
}
}
@@ -0,0 +1,119 @@
package cn.craftlabs.platform.api.persistence.contract;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.OffsetDateTime;
@TableName("platform_contract_change")
public class PlatformContractChange {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("contract_id")
private Long contractId;
private Integer version;
@TableField("change_type")
private String changeType;
private String reason;
@TableField("change_summary")
private String changeSummary;
private String status;
@TableField("created_by")
private String createdBy;
@TableField("created_at")
private OffsetDateTime createdAt;
@TableField("completed_at")
private OffsetDateTime completedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getContractId() {
return contractId;
}
public void setContractId(Long contractId) {
this.contractId = contractId;
}
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
this.version = version;
}
public String getChangeType() {
return changeType;
}
public void setChangeType(String changeType) {
this.changeType = changeType;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public String getChangeSummary() {
return changeSummary;
}
public void setChangeSummary(String changeSummary) {
this.changeSummary = changeSummary;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getCreatedBy() {
return createdBy;
}
public void setCreatedBy(String createdBy) {
this.createdBy = createdBy;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getCompletedAt() {
return completedAt;
}
public void setCompletedAt(OffsetDateTime completedAt) {
this.completedAt = completedAt;
}
}
@@ -0,0 +1,8 @@
package cn.craftlabs.platform.api.persistence.contract;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlatformContractChangeMapper extends BaseMapper<PlatformContractChange> {
}
@@ -1,8 +1,15 @@
package cn.craftlabs.platform.api.service;
import cn.craftlabs.platform.api.domain.ContractStatus;
import cn.craftlabs.platform.api.persistence.contract.PlatformContract;
import cn.craftlabs.platform.api.persistence.contract.PlatformContractChange;
import cn.craftlabs.platform.api.persistence.contract.PlatformContractChangeMapper;
import cn.craftlabs.platform.api.persistence.contract.PlatformContractMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import java.time.OffsetDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
/**
@@ -13,6 +20,14 @@ import org.springframework.web.server.ResponseStatusException;
@Service
public class ContractStatusTransitionService {
private final PlatformContractMapper contractMapper;
private final PlatformContractChangeMapper changeMapper;
public ContractStatusTransitionService(PlatformContractMapper contractMapper, PlatformContractChangeMapper changeMapper) {
this.contractMapper = contractMapper;
this.changeMapper = changeMapper;
}
public void requireTransition(ContractStatus from, ContractStatus to) {
if (from == to) {
return;
@@ -39,4 +54,56 @@ public class ContractStatusTransitionService {
}
return false;
}
@Transactional
public void initiateChange(Long contractId, String reason) {
PlatformContract c = contractMapper.selectById(contractId);
if (c == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract not found");
}
requireTransition(ContractStatus.valueOf(c.getStatus()), ContractStatus.CHANGING);
var query = Wrappers.lambdaQuery(PlatformContractChange.class)
.eq(PlatformContractChange::getContractId, contractId)
.orderByDesc(PlatformContractChange::getVersion);
PlatformContractChange latest = changeMapper.selectOne(query);
int nextVersion = latest != null ? latest.getVersion() + 1 : 1;
PlatformContractChange change = new PlatformContractChange();
change.setContractId(contractId);
change.setVersion(nextVersion);
change.setChangeType("AMENDMENT");
change.setReason(reason);
change.setStatus("DRAFT");
change.setCreatedAt(OffsetDateTime.now());
changeMapper.insert(change);
c.setStatus(ContractStatus.CHANGING.name());
c.setUpdatedAt(OffsetDateTime.now());
contractMapper.updateById(c);
}
@Transactional
public void completeChange(Long contractId) {
PlatformContract c = contractMapper.selectById(contractId);
if (c == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract not found");
}
requireTransition(ContractStatus.valueOf(c.getStatus()), ContractStatus.EFFECTIVE);
var query = Wrappers.lambdaQuery(PlatformContractChange.class)
.eq(PlatformContractChange::getContractId, contractId)
.eq(PlatformContractChange::getStatus, "DRAFT")
.orderByDesc(PlatformContractChange::getVersion);
PlatformContractChange change = changeMapper.selectOne(query);
if (change != null) {
change.setStatus("COMPLETED");
change.setCompletedAt(OffsetDateTime.now());
changeMapper.updateById(change);
}
c.setStatus(ContractStatus.EFFECTIVE.name());
c.setUpdatedAt(OffsetDateTime.now());
contractMapper.updateById(c);
}
}
@@ -0,0 +1,15 @@
-- V11__contract_changes.sql
CREATE TABLE platform_contract_change (
id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES platform_contract(id),
version INT NOT NULL DEFAULT 1,
change_type VARCHAR(64) NOT NULL DEFAULT 'AMENDMENT',
reason TEXT,
change_summary TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
created_by VARCHAR(256),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_contract_change_contract ON platform_contract_change(contract_id);