feat(platform): I3 contracts, lines, status machine, and audit API

Add Flyway V3 tables, contract CRUD and line endpoints, PATCH status
transitions with validation, M10-F01 audit-events listing, 409 handler,
and integration tests. Refresh OpenAPI contract snapshot.

Made-with: Cursor
This commit is contained in:
2026-04-06 21:29:21 +08:00
parent 5b50bf0fd8
commit 69f7ee11df
26 changed files with 2439 additions and 0 deletions
@@ -0,0 +1,159 @@
package cn.craftlabs.platform.api.contracts;
import cn.craftlabs.platform.api.support.JwtTestSupport;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ContractControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void contractDraftLineTransitionAuditAndIllegalTransition() throws Exception {
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
String auth = "Bearer " + token;
String customerBody = "{\"name\":\"合同客户\",\"creditCode\":\"CC001\",\"status\":\"ACTIVE\"}";
String customerJson =
mockMvc.perform(
post("/api/v1/customers")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(customerBody))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
long customerId = objectMapper.readTree(customerJson).get("id").asLong();
String projectBody =
String.format(
"{\"customerId\":%d,\"name\":\"合同项目\",\"phase\":\"PLANNING\"}", customerId);
String projectJson =
mockMvc.perform(
post("/api/v1/projects")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(projectBody))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
long projectId = objectMapper.readTree(projectJson).get("id").asLong();
String contractBody =
String.format(
"{\"customerId\":%d,\"projectId\":%d,\"title\":\"框架协议\",\"remarks\":\"备注\"}",
customerId, projectId);
String contractJson =
mockMvc.perform(
post("/api/v1/contracts")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(contractBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("DRAFT"))
.andReturn()
.getResponse()
.getContentAsString();
long contractId = objectMapper.readTree(contractJson).get("id").asLong();
mockMvc.perform(
get("/api/v1/contracts")
.header("Authorization", auth)
.param("page", "0")
.param("size", "10")
.param("keyword", "框架"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].id").value(contractId))
.andExpect(jsonPath("$.content[0].lines").doesNotExist());
String lineBody =
"{\"itemName\":\"交付项A\",\"quantity\":2,\"unit\":\"\",\"amount\":10000,\"remark\":\"首行\"}";
mockMvc.perform(
post("/api/v1/contracts/" + contractId + "/lines")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(lineBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.itemName").value("交付项A"));
mockMvc.perform(
patch("/api/v1/contracts/" + contractId + "/status")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PENDING_EFFECTIVE\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PENDING_EFFECTIVE"));
mockMvc.perform(
patch("/api/v1/contracts/" + contractId + "/status")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"EFFECTIVE\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("EFFECTIVE"));
mockMvc.perform(
patch("/api/v1/contracts/" + contractId + "/status")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"DRAFT\"}"))
.andExpect(status().isConflict())
.andExpect(
jsonPath("$.message")
.value(containsString("illegal contract status transition")));
String auditBody =
mockMvc.perform(
get("/api/v1/audit-events")
.header("Authorization", auth)
.param("entityType", "CONTRACT")
.param("entityId", String.valueOf(contractId))
.param("page", "0")
.param("size", "50"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(4))
.andReturn()
.getResponse()
.getContentAsString();
JsonNode root = objectMapper.readTree(auditBody);
boolean hasCreated = false;
boolean hasLine = false;
for (JsonNode row : root.get("content")) {
String action = row.get("action").asText();
if ("CONTRACT_CREATED".equals(action)) {
hasCreated = true;
}
if ("CONTRACT_LINE_ADDED".equals(action)) {
hasLine = true;
}
}
assertThat(hasCreated).isTrue();
assertThat(hasLine).isTrue();
assertThat(root.get("number").asInt()).isZero();
}
}
@@ -0,0 +1,110 @@
package cn.craftlabs.platform.api.service;
import cn.craftlabs.platform.api.domain.ContractStatus;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContractStatusTransitionServiceTest {
private final ContractStatusTransitionService service = new ContractStatusTransitionService();
@Test
void sameStatusIsNoOp() {
assertThatCode(() -> service.requireTransition(ContractStatus.DRAFT, ContractStatus.DRAFT))
.doesNotThrowAnyException();
assertThatCode(() -> service.requireTransition(ContractStatus.EFFECTIVE, ContractStatus.EFFECTIVE))
.doesNotThrowAnyException();
}
@Test
void happyPathMainFlow() {
assertThatCode(
() ->
service.requireTransition(
ContractStatus.DRAFT, ContractStatus.PENDING_EFFECTIVE))
.doesNotThrowAnyException();
assertThatCode(
() ->
service.requireTransition(
ContractStatus.PENDING_EFFECTIVE, ContractStatus.EFFECTIVE))
.doesNotThrowAnyException();
assertThatCode(
() ->
service.requireTransition(
ContractStatus.EFFECTIVE, ContractStatus.CHANGING))
.doesNotThrowAnyException();
assertThatCode(
() ->
service.requireTransition(
ContractStatus.CHANGING, ContractStatus.EFFECTIVE))
.doesNotThrowAnyException();
}
/** 自 {@link ContractStatus#EFFECTIVE} 可进入 {@link ContractStatus#TERMINATED}(解约/终止)。 */
@Test
void effectiveToTerminatedAllowed() {
assertThatCode(
() ->
service.requireTransition(
ContractStatus.EFFECTIVE, ContractStatus.TERMINATED))
.doesNotThrowAnyException();
}
@ParameterizedTest
@EnumSource(
value = ContractStatus.class,
names = {"EFFECTIVE", "CHANGING", "TERMINATED"})
void draftRejectsSkipPendingEffective(ContractStatus to) {
assertConflict(ContractStatus.DRAFT, to);
}
@Test
void effectiveToDraftRejected() {
assertConflict(ContractStatus.EFFECTIVE, ContractStatus.DRAFT);
}
@Test
void changingToTerminatedRejected() {
assertConflict(ContractStatus.CHANGING, ContractStatus.TERMINATED);
}
@Test
void terminatedIsTerminal() {
assertThatCode(
() ->
service.requireTransition(
ContractStatus.TERMINATED, ContractStatus.TERMINATED))
.doesNotThrowAnyException();
for (ContractStatus to : ContractStatus.values()) {
if (to == ContractStatus.TERMINATED) {
continue;
}
assertConflict(ContractStatus.TERMINATED, to);
}
}
@Test
void illegalMessageMentionsTransition() {
assertThatThrownBy(() -> service.requireTransition(ContractStatus.DRAFT, ContractStatus.EFFECTIVE))
.isInstanceOfSatisfying(
ResponseStatusException.class,
ex -> {
assertThat(ex.getStatusCode().value()).isEqualTo(409);
assertThat(ex.getReason()).contains("illegal contract status transition");
});
}
private void assertConflict(ContractStatus from, ContractStatus to) {
assertThatThrownBy(() -> service.requireTransition(from, to))
.isInstanceOfSatisfying(
ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
}
}