feat(platform): I4 delivery batches, lines, and license SN APIs

Add Flyway V4 tables, delivery-batches and license-sns endpoints with
validation, audit actions, controller tests, and OpenAPI snapshot update.

Made-with: Cursor
This commit is contained in:
2026-04-06 21:49:04 +08:00
parent df91ab0673
commit 9df6f60a17
28 changed files with 3151 additions and 14 deletions
@@ -0,0 +1,250 @@
package cn.craftlabs.platform.api.delivery;
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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class DeliveryBatchControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void deliveryBatchLinesStatusAuditAndGuards() throws Exception {
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
String auth = "Bearer " + token;
String customerBody = "{\"name\":\"交付客户\",\"creditCode\":\"DB001\",\"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 otherProjectBody =
String.format(
"{\"customerId\":%d,\"name\":\"其他项目\",\"phase\":\"PLANNING\"}", customerId);
String otherProjectJson =
mockMvc.perform(
post("/api/v1/projects")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(otherProjectBody))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
long otherProjectId = objectMapper.readTree(otherProjectJson).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())
.andReturn()
.getResponse()
.getContentAsString();
long contractId = objectMapper.readTree(contractJson).get("id").asLong();
String lineBody = "{\"itemName\":\"合同行A\",\"quantity\":1,\"unit\":\"\",\"amount\":1,\"remark\":\"\"}";
String lineJson =
mockMvc.perform(
post("/api/v1/contracts/" + contractId + "/lines")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(lineBody))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
long contractLineId = objectMapper.readTree(lineJson).get("id").asLong();
String wrongContractBody =
String.format(
"{\"customerId\":%d,\"projectId\":%d,\"title\":\"错项目合同\",\"remarks\":\"\"}",
customerId, otherProjectId);
String wrongContractJson =
mockMvc.perform(
post("/api/v1/contracts")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(wrongContractBody))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
long wrongContractId = objectMapper.readTree(wrongContractJson).get("id").asLong();
String batchMismatch =
String.format(
"{\"projectId\":%d,\"contractId\":%d,\"batchCode\":\"B-MISMATCH\","
+ "\"plannedDeliveryDate\":\"2026-05-01\",\"remarks\":\"x\"}",
projectId, wrongContractId);
mockMvc.perform(
post("/api/v1/delivery-batches")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(batchMismatch))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(containsString("projectId")));
String batchCreate =
String.format(
"{\"projectId\":%d,\"contractId\":%d,\"batchCode\":\"B-001\","
+ "\"plannedDeliveryDate\":\"2026-04-01\",\"remarks\":\"首批\"}",
projectId, contractId);
String batchJson =
mockMvc.perform(
post("/api/v1/delivery-batches")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(batchCreate))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("PENDING"))
.andReturn()
.getResponse()
.getContentAsString();
long batchId = objectMapper.readTree(batchJson).get("id").asLong();
mockMvc.perform(
post("/api/v1/delivery-batches")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(batchCreate))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value(containsString("duplicate")));
mockMvc.perform(
get("/api/v1/delivery-batches")
.header("Authorization", auth)
.param("page", "0")
.param("size", "10")
.param("keyword", "B-0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].id").value(batchId))
.andExpect(jsonPath("$.content[0].lines").doesNotExist());
mockMvc.perform(get("/api/v1/delivery-batches/" + batchId).header("Authorization", auth))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lines").isArray());
String dLine =
String.format(
"{\"description\":\"清单项\",\"quantity\":2,\"contractLineId\":%d}",
contractLineId);
mockMvc.perform(
post("/api/v1/delivery-batches/" + batchId + "/lines")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(dLine))
.andExpect(status().isCreated());
mockMvc.perform(
put("/api/v1/delivery-batches/" + batchId)
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"remarks\":\"改备注\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.remarks").value("改备注"));
mockMvc.perform(
patch("/api/v1/delivery-batches/" + batchId + "/status")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"DELIVERED\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("DELIVERED"))
.andExpect(jsonPath("$.finishedAt").exists());
mockMvc.perform(
put("/api/v1/delivery-batches/" + batchId)
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"remarks\":\"迟了\"}"))
.andExpect(status().isConflict());
mockMvc.perform(
post("/api/v1/delivery-batches/" + batchId + "/lines")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"description\":\"晚加\",\"quantity\":1}"))
.andExpect(status().isConflict());
String auditBody =
mockMvc.perform(
get("/api/v1/audit-events")
.header("Authorization", auth)
.param("entityType", "DELIVERY_BATCH")
.param("entityId", String.valueOf(batchId))
.param("page", "0")
.param("size", "50"))
.andExpect(status().isOk())
.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 ("DELIVERY_BATCH_CREATED".equals(action)) {
hasCreated = true;
}
if ("DELIVERY_LINE_ADDED".equals(action)) {
hasLine = true;
}
}
assertThat(hasCreated).isTrue();
assertThat(hasLine).isTrue();
}
}
@@ -0,0 +1,195 @@
package cn.craftlabs.platform.api.license;
import cn.craftlabs.platform.api.support.JwtTestSupport;
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.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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class LicenseSnControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void licenseSnCreateDuplicateStatusTransitionsAndRevoke() throws Exception {
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
String auth = "Bearer " + token;
String customerBody = "{\"name\":\"SN客户\",\"creditCode\":\"SN001\",\"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\":\"SN项目\",\"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\":\"SN合同\",\"remarks\":\"\"}",
customerId, projectId);
String contractJson =
mockMvc.perform(
post("/api/v1/contracts")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(contractBody))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
long contractId = objectMapper.readTree(contractJson).get("id").asLong();
String lineBody = "{\"itemName\":\"许可行\",\"quantity\":1,\"unit\":\"\",\"amount\":1,\"remark\":\"\"}";
String lineJson =
mockMvc.perform(
post("/api/v1/contracts/" + contractId + "/lines")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(lineBody))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
long contractLineId = objectMapper.readTree(lineJson).get("id").asLong();
mockMvc.perform(
post("/api/v1/license-sns")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"snCode\":\" SN-001 \",\"activationRemark\":\"a\"}"))
.andExpect(status().isBadRequest());
String createByProject =
String.format(
"{\"snCode\":\"SN-001\",\"projectId\":%d,\"activationRemark\":\"备注\"}",
projectId);
String snJson =
mockMvc.perform(
post("/api/v1/license-sns")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(createByProject))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("REGISTERED"))
.andExpect(jsonPath("$.snCode").value("SN-001"))
.andReturn()
.getResponse()
.getContentAsString();
long snId = objectMapper.readTree(snJson).get("id").asLong();
mockMvc.perform(
post("/api/v1/license-sns")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(createByProject))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value(containsString("duplicate")));
String createByLineOnly =
String.format("{\"snCode\":\"SN-LINE\",\"contractLineId\":%d}", contractLineId);
mockMvc.perform(
post("/api/v1/license-sns")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(createByLineOnly))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.projectId").value(projectId))
.andExpect(jsonPath("$.contractLineId").value(contractLineId));
mockMvc.perform(
get("/api/v1/license-sns")
.header("Authorization", auth)
.param("page", "0")
.param("size", "20")
.param("projectId", String.valueOf(projectId))
.param("keyword", "SN-")
.param("status", "REGISTERED"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(2));
mockMvc.perform(
patch("/api/v1/license-sns/" + snId + "/status")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"ACTIVATED\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value(containsString("illegal")));
mockMvc.perform(
patch("/api/v1/license-sns/" + snId + "/status")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"ISSUED\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("ISSUED"));
mockMvc.perform(
patch("/api/v1/license-sns/" + snId + "/status")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"ACTIVATED\"}"))
.andExpect(status().isOk());
mockMvc.perform(
put("/api/v1/license-sns/" + snId)
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"activationRemark\":\"已激活\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.activationRemark").value("已激活"));
mockMvc.perform(
patch("/api/v1/license-sns/" + snId + "/status")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"REVOKED\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("REVOKED"));
mockMvc.perform(
put("/api/v1/license-sns/" + snId)
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"activationRemark\":\"no\"}"))
.andExpect(status().isConflict());
}
}