diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java index cbbd408..43e5636 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java @@ -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 initiateChange(@PathVariable Long id, @RequestBody Map body) { + contractStatusTransitionService.initiateChange(id, body.getOrDefault("reason", "")); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{id}/changes/complete") + public ResponseEntity completeChange(@PathVariable Long id) { + contractStatusTransitionService.completeChange(id); + return ResponseEntity.ok().build(); + } } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractChange.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractChange.java new file mode 100644 index 0000000..d6a1930 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractChange.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractChangeMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractChangeMapper.java new file mode 100644 index 0000000..b309bd3 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractChangeMapper.java @@ -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 { +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractStatusTransitionService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractStatusTransitionService.java index 9ae6b19..24eec04 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractStatusTransitionService.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractStatusTransitionService.java @@ -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); + } } diff --git a/services/delivery-platform-api/src/main/resources/db/migration/V11__contract_changes.sql b/services/delivery-platform-api/src/main/resources/db/migration/V11__contract_changes.sql new file mode 100644 index 0000000..1cabd1d --- /dev/null +++ b/services/delivery-platform-api/src/main/resources/db/migration/V11__contract_changes.sql @@ -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); diff --git a/web/delivery-platform-ui/src/api/platform.js b/web/delivery-platform-ui/src/api/platform.js index a2ec2f5..7ed83c4 100644 --- a/web/delivery-platform-ui/src/api/platform.js +++ b/web/delivery-platform-ui/src/api/platform.js @@ -379,3 +379,11 @@ export function getCallbackStats(params) { export function getProjectHealth() { 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`); +} diff --git a/web/delivery-platform-ui/src/views/ContractDetailView.vue b/web/delivery-platform-ui/src/views/ContractDetailView.vue index 028f15a..f11f74d 100644 --- a/web/delivery-platform-ui/src/views/ContractDetailView.vue +++ b/web/delivery-platform-ui/src/views/ContractDetailView.vue @@ -132,6 +132,8 @@ import { listProjects, uploadContractAttachment, listContractAttachments, + initiateContractChange, + completeContractChange, } from "../api/platform"; 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 isEffective = computed(() => String(contract.value?.status ?? "").toUpperCase() === "EFFECTIVE"); +const isChanging = computed(() => String(contract.value?.status ?? "").toUpperCase() === "CHANGING"); const lineRows = computed(() => { const c = contract.value; @@ -475,7 +478,13 @@ function onTransition(btn) { if (id == null) return; transitionLoading.value = btn.status; try { - await patchContractStatus(id, { status: btn.status }); + 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 }); + } ElMessage.success("状态已更新"); await refreshAll(); } catch (e) {