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.PlatformContractAttachment;
import cn.craftlabs.platform.api.persistence.attachment.PlatformContractAttachmentMapper; import cn.craftlabs.platform.api.persistence.attachment.PlatformContractAttachmentMapper;
import cn.craftlabs.platform.api.service.ContractService; 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.ContractCreateRequest;
import cn.craftlabs.platform.api.web.dto.ContractLineRequest; import cn.craftlabs.platform.api.web.dto.ContractLineRequest;
import cn.craftlabs.platform.api.web.dto.ContractLineResponse; import cn.craftlabs.platform.api.web.dto.ContractLineResponse;
@@ -41,10 +42,12 @@ public class ContractController {
private final ContractService contractService; private final ContractService contractService;
private final PlatformContractAttachmentMapper attachmentMapper; 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.contractService = contractService;
this.attachmentMapper = attachmentMapper; this.attachmentMapper = attachmentMapper;
this.contractStatusTransitionService = contractStatusTransitionService;
} }
@GetMapping @GetMapping
@@ -141,4 +144,16 @@ public class ContractController {
.orderByDesc(PlatformContractAttachment::getCreatedAt); .orderByDesc(PlatformContractAttachment::getCreatedAt);
return ResponseEntity.ok(attachmentMapper.selectList(query)); 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; package cn.craftlabs.platform.api.service;
import cn.craftlabs.platform.api.domain.ContractStatus; 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.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
/** /**
@@ -13,6 +20,14 @@ import org.springframework.web.server.ResponseStatusException;
@Service @Service
public class ContractStatusTransitionService { 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) { public void requireTransition(ContractStatus from, ContractStatus to) {
if (from == to) { if (from == to) {
return; return;
@@ -39,4 +54,56 @@ public class ContractStatusTransitionService {
} }
return false; 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);
@@ -379,3 +379,11 @@ export function getCallbackStats(params) {
export function getProjectHealth() { export function getProjectHealth() {
return axios.get('/api/v1/reports/project-health'); return axios.get('/api/v1/reports/project-health');
} }
// —— I11 合同变更版本 ——————————————————————————
export function initiateContractChange(id, body) {
return axios.post(`/api/v1/contracts/${id}/changes`, body);
}
export function completeContractChange(id) {
return axios.post(`/api/v1/contracts/${id}/changes/complete`);
}
@@ -132,6 +132,8 @@ import {
listProjects, listProjects,
uploadContractAttachment, uploadContractAttachment,
listContractAttachments, listContractAttachments,
initiateContractChange,
completeContractChange,
} from "../api/platform"; } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage"; import { apiErrorMessage } from "../utils/apiErrorMessage";
@@ -176,6 +178,7 @@ const contractId = computed(() => route.params.id);
const isDraft = computed(() => String(contract.value?.status ?? "").toUpperCase() === "DRAFT"); const isDraft = computed(() => String(contract.value?.status ?? "").toUpperCase() === "DRAFT");
const isEffective = computed(() => String(contract.value?.status ?? "").toUpperCase() === "EFFECTIVE"); const isEffective = computed(() => String(contract.value?.status ?? "").toUpperCase() === "EFFECTIVE");
const isChanging = computed(() => String(contract.value?.status ?? "").toUpperCase() === "CHANGING");
const lineRows = computed(() => { const lineRows = computed(() => {
const c = contract.value; const c = contract.value;
@@ -475,7 +478,13 @@ function onTransition(btn) {
if (id == null) return; if (id == null) return;
transitionLoading.value = btn.status; transitionLoading.value = btn.status;
try { try {
if (btn.status === "CHANGING") {
await initiateContractChange(id, { reason: "" });
} else if (btn.status === "EFFECTIVE" && isChanging.value) {
await completeContractChange(id);
} else {
await patchContractStatus(id, { status: btn.status }); await patchContractStatus(id, { status: btn.status });
}
ElMessage.success("状态已更新"); ElMessage.success("状态已更新");
await refreshAll(); await refreshAll();
} catch (e) { } catch (e) {