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
@@ -170,6 +170,139 @@
}
}
},
"/api/v1/contracts/{id}" : {
"get" : {
"tags" : [ "contract-controller" ],
"operationId" : "get_2",
"parameters" : [ {
"name" : "id",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"responses" : {
"200" : {
"description" : "OK",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ContractResponse"
}
}
}
}
}
},
"put" : {
"tags" : [ "contract-controller" ],
"operationId" : "update_2",
"parameters" : [ {
"name" : "id",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ContractUpdateRequest"
}
}
},
"required" : true
},
"responses" : {
"200" : {
"description" : "OK",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ContractResponse"
}
}
}
}
}
}
},
"/api/v1/contracts/{id}/lines/{lineId}" : {
"put" : {
"tags" : [ "contract-controller" ],
"operationId" : "updateLine",
"parameters" : [ {
"name" : "id",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
}, {
"name" : "lineId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ContractLineRequest"
}
}
},
"required" : true
},
"responses" : {
"200" : {
"description" : "OK",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ContractLineResponse"
}
}
}
}
}
},
"delete" : {
"tags" : [ "contract-controller" ],
"operationId" : "deleteLine",
"parameters" : [ {
"name" : "id",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
}, {
"name" : "lineId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"responses" : {
"204" : {
"description" : "No Content"
}
}
}
},
"/api/v1/projects" : {
"get" : {
"tags" : [ "project-controller" ],
@@ -317,6 +450,160 @@
}
}
},
"/api/v1/contracts" : {
"get" : {
"tags" : [ "contract-controller" ],
"operationId" : "list_2",
"parameters" : [ {
"name" : "page",
"in" : "query",
"required" : false,
"schema" : {
"type" : "integer",
"format" : "int32",
"default" : 0,
"minimum" : 0
}
}, {
"name" : "size",
"in" : "query",
"required" : false,
"schema" : {
"type" : "integer",
"format" : "int32",
"default" : 20,
"maximum" : 200,
"minimum" : 1
}
}, {
"name" : "customerId",
"in" : "query",
"required" : false,
"schema" : {
"type" : "integer",
"format" : "int64"
}
}, {
"name" : "projectId",
"in" : "query",
"required" : false,
"schema" : {
"type" : "integer",
"format" : "int64"
}
}, {
"name" : "keyword",
"in" : "query",
"required" : false,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"200" : {
"description" : "OK",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/PageResponseContractResponse"
}
}
}
}
}
},
"post" : {
"tags" : [ "contract-controller" ],
"operationId" : "create_2",
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ContractCreateRequest"
}
}
},
"required" : true
},
"responses" : {
"201" : {
"description" : "Created",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ContractResponse"
}
}
}
}
}
}
},
"/api/v1/contracts/{id}/lines" : {
"get" : {
"tags" : [ "contract-controller" ],
"operationId" : "listLines",
"parameters" : [ {
"name" : "id",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"responses" : {
"200" : {
"description" : "OK",
"content" : {
"*/*" : {
"schema" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/ContractLineResponse"
}
}
}
}
}
}
},
"post" : {
"tags" : [ "contract-controller" ],
"operationId" : "addLine",
"parameters" : [ {
"name" : "id",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ContractLineRequest"
}
}
},
"required" : true
},
"responses" : {
"201" : {
"description" : "Created",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ContractLineResponse"
}
}
}
}
}
}
},
"/api/v1/auth/login" : {
"post" : {
"tags" : [ "auth-controller" ],
@@ -349,6 +636,43 @@
}
}
},
"/api/v1/contracts/{id}/status" : {
"patch" : {
"tags" : [ "contract-controller" ],
"operationId" : "patchStatus",
"parameters" : [ {
"name" : "id",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ContractStatusPatchRequest"
}
}
},
"required" : true
},
"responses" : {
"200" : {
"description" : "OK",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ContractResponse"
}
}
}
}
}
}
},
"/api/v1/ping" : {
"get" : {
"tags" : [ "ping-controller" ],
@@ -398,6 +722,62 @@
}
}
}
},
"/api/v1/audit-events" : {
"get" : {
"tags" : [ "audit-controller" ],
"operationId" : "list_3",
"parameters" : [ {
"name" : "entityType",
"in" : "query",
"required" : true,
"schema" : {
"type" : "string",
"minLength" : 1
}
}, {
"name" : "entityId",
"in" : "query",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
}, {
"name" : "page",
"in" : "query",
"required" : false,
"schema" : {
"type" : "integer",
"format" : "int32",
"default" : 0,
"minimum" : 0
}
}, {
"name" : "size",
"in" : "query",
"required" : false,
"schema" : {
"type" : "integer",
"format" : "int32",
"default" : 20,
"maximum" : 200,
"minimum" : 1
}
} ],
"responses" : {
"200" : {
"description" : "OK",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/PageResponseAuditEventResponse"
}
}
}
}
}
}
}
},
"components" : {
@@ -496,6 +876,167 @@
}
}
},
"ContractUpdateRequest" : {
"type" : "object",
"properties" : {
"title" : {
"type" : "string",
"maxLength" : 256,
"minLength" : 0
},
"remarks" : {
"type" : "string",
"maxLength" : 4000,
"minLength" : 0
}
}
},
"ContractLineResponse" : {
"type" : "object",
"properties" : {
"id" : {
"type" : "integer",
"format" : "int64"
},
"contractId" : {
"type" : "integer",
"format" : "int64"
},
"sortOrder" : {
"type" : "integer",
"format" : "int32"
},
"itemName" : {
"type" : "string"
},
"quantity" : {
"type" : "number"
},
"unit" : {
"type" : "string"
},
"amount" : {
"type" : "number"
},
"remark" : {
"type" : "string"
},
"createdAt" : {
"type" : "string",
"format" : "date-time"
},
"updatedAt" : {
"type" : "string",
"format" : "date-time"
}
}
},
"ContractResponse" : {
"type" : "object",
"properties" : {
"id" : {
"type" : "integer",
"format" : "int64"
},
"customerId" : {
"type" : "integer",
"format" : "int64"
},
"projectId" : {
"type" : "integer",
"format" : "int64"
},
"title" : {
"type" : "string"
},
"remarks" : {
"type" : "string"
},
"status" : {
"type" : "string"
},
"createdAt" : {
"type" : "string",
"format" : "date-time"
},
"updatedAt" : {
"type" : "string",
"format" : "date-time"
},
"lines" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/ContractLineResponse"
}
}
}
},
"ContractLineRequest" : {
"type" : "object",
"properties" : {
"sortOrder" : {
"type" : "integer",
"format" : "int32"
},
"itemName" : {
"type" : "string",
"maxLength" : 256,
"minLength" : 0
},
"quantity" : {
"type" : "number",
"minimum" : 1.0E-4
},
"unit" : {
"type" : "string",
"maxLength" : 32,
"minLength" : 0
},
"amount" : {
"type" : "number"
},
"remark" : {
"type" : "string",
"maxLength" : 512,
"minLength" : 0
}
},
"required" : [ "itemName", "quantity" ]
},
"ContractCreateRequest" : {
"type" : "object",
"properties" : {
"customerId" : {
"type" : "integer",
"format" : "int64"
},
"projectId" : {
"type" : "integer",
"format" : "int64"
},
"title" : {
"type" : "string",
"maxLength" : 256,
"minLength" : 0
},
"remarks" : {
"type" : "string",
"maxLength" : 4000,
"minLength" : 0
}
},
"required" : [ "customerId", "projectId" ]
},
"ContractStatusPatchRequest" : {
"type" : "object",
"properties" : {
"status" : {
"type" : "string",
"minLength" : 1
}
},
"required" : [ "status" ]
},
"PageResponseProjectResponse" : {
"type" : "object",
"properties" : {
@@ -556,6 +1097,87 @@
"format" : "int32"
}
}
},
"PageResponseContractResponse" : {
"type" : "object",
"properties" : {
"content" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/ContractResponse"
}
},
"totalElements" : {
"type" : "integer",
"format" : "int64"
},
"number" : {
"type" : "integer",
"format" : "int32"
},
"size" : {
"type" : "integer",
"format" : "int32"
}
}
},
"AuditEventResponse" : {
"type" : "object",
"properties" : {
"id" : {
"type" : "integer",
"format" : "int64"
},
"entityType" : {
"type" : "string"
},
"entityId" : {
"type" : "integer",
"format" : "int64"
},
"action" : {
"type" : "string"
},
"fieldName" : {
"type" : "string"
},
"oldValue" : {
"type" : "string"
},
"newValue" : {
"type" : "string"
},
"actorUserId" : {
"type" : "string"
},
"createdAt" : {
"type" : "string",
"format" : "date-time"
}
}
},
"PageResponseAuditEventResponse" : {
"type" : "object",
"properties" : {
"content" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/AuditEventResponse"
}
},
"totalElements" : {
"type" : "integer",
"format" : "int64"
},
"number" : {
"type" : "integer",
"format" : "int32"
},
"size" : {
"type" : "integer",
"format" : "int32"
}
}
}
},
"securitySchemes" : {
@@ -0,0 +1,14 @@
package cn.craftlabs.platform.api.audit;
/** 审计动作常量(M10-F01)。 */
public final class AuditActions {
public static final String CONTRACT_CREATED = "CONTRACT_CREATED";
public static final String CONTRACT_UPDATED = "CONTRACT_UPDATED";
public static final String CONTRACT_LINE_ADDED = "CONTRACT_LINE_ADDED";
public static final String CONTRACT_LINE_UPDATED = "CONTRACT_LINE_UPDATED";
public static final String CONTRACT_LINE_DELETED = "CONTRACT_LINE_DELETED";
public static final String CONTRACT_STATUS_CHANGED = "CONTRACT_STATUS_CHANGED";
private AuditActions() {}
}
@@ -0,0 +1,35 @@
package cn.craftlabs.platform.api.audit;
import cn.craftlabs.platform.api.service.AuditService;
import cn.craftlabs.platform.api.web.dto.AuditEventResponse;
import cn.craftlabs.platform.api.web.dto.PageResponse;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/audit-events")
@Validated
public class AuditController {
private final AuditService auditService;
public AuditController(AuditService auditService) {
this.auditService = auditService;
}
@GetMapping
public PageResponse<AuditEventResponse> list(
@RequestParam("entityType") @NotBlank String entityType,
@RequestParam("entityId") @NotNull Long entityId,
@RequestParam(value = "page", defaultValue = "0") @Min(0) int page,
@RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size) {
return auditService.page(entityType, entityId, page, size);
}
}
@@ -0,0 +1,8 @@
package cn.craftlabs.platform.api.audit;
public final class AuditEntityTypes {
public static final String CONTRACT = "CONTRACT";
private AuditEntityTypes() {}
}
@@ -0,0 +1,21 @@
package cn.craftlabs.platform.api.config;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashMap;
import java.util.Map;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handleResponseStatus(ResponseStatusException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", ex.getStatusCode().value());
body.put("message", ex.getReason() != null ? ex.getReason() : "");
return ResponseEntity.status(ex.getStatusCode()).body(body);
}
}
@@ -0,0 +1,100 @@
package cn.craftlabs.platform.api.contracts;
import cn.craftlabs.platform.api.service.ContractService;
import cn.craftlabs.platform.api.web.dto.ContractCreateRequest;
import cn.craftlabs.platform.api.web.dto.ContractLineRequest;
import cn.craftlabs.platform.api.web.dto.ContractLineResponse;
import cn.craftlabs.platform.api.web.dto.ContractResponse;
import cn.craftlabs.platform.api.web.dto.ContractStatusPatchRequest;
import cn.craftlabs.platform.api.web.dto.ContractUpdateRequest;
import cn.craftlabs.platform.api.web.dto.PageResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 合同 API:头信息与行挂在同一资源树下(嵌套路由)。 */
@RestController
@RequestMapping("/api/v1/contracts")
@Validated
public class ContractController {
private final ContractService contractService;
public ContractController(ContractService contractService) {
this.contractService = contractService;
}
@GetMapping
public PageResponse<ContractResponse> list(
@RequestParam(value = "page", defaultValue = "0") @Min(0) int page,
@RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size,
@RequestParam(value = "customerId", required = false) Long customerId,
@RequestParam(value = "projectId", required = false) Long projectId,
@RequestParam(value = "keyword", required = false) String keyword) {
return contractService.page(page, size, customerId, projectId, keyword);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ContractResponse create(@Valid @RequestBody ContractCreateRequest request) {
return contractService.create(request);
}
@GetMapping("/{id}")
public ContractResponse get(@PathVariable("id") long id) {
return contractService.getById(id);
}
@PutMapping("/{id}")
public ContractResponse update(
@PathVariable("id") long id, @Valid @RequestBody ContractUpdateRequest request) {
return contractService.update(id, request);
}
@PatchMapping("/{id}/status")
public ContractResponse patchStatus(
@PathVariable("id") long id, @Valid @RequestBody ContractStatusPatchRequest request) {
return contractService.patchStatus(id, request);
}
@GetMapping("/{id}/lines")
public List<ContractLineResponse> listLines(@PathVariable("id") long contractId) {
return contractService.listLines(contractId);
}
@PostMapping("/{id}/lines")
@ResponseStatus(HttpStatus.CREATED)
public ContractLineResponse addLine(
@PathVariable("id") long contractId, @Valid @RequestBody ContractLineRequest request) {
return contractService.addLine(contractId, request);
}
@PutMapping("/{id}/lines/{lineId}")
public ContractLineResponse updateLine(
@PathVariable("id") long contractId,
@PathVariable("lineId") long lineId,
@Valid @RequestBody ContractLineRequest request) {
return contractService.updateLine(contractId, lineId, request);
}
@DeleteMapping("/{id}/lines/{lineId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteLine(@PathVariable("id") long contractId, @PathVariable("lineId") long lineId) {
contractService.deleteLine(contractId, lineId);
}
}
@@ -0,0 +1,20 @@
package cn.craftlabs.platform.api.domain;
/**
* 合同生命周期状态。
*
* <p>允许的状态迁移(非法迁移返回 409):
*
* <ul>
* <li>{@link #DRAFT} → {@link #PENDING_EFFECTIVE} → {@link #EFFECTIVE}
* <li>{@link #EFFECTIVE} → {@link #CHANGING} → {@link #EFFECTIVE}
* <li>{@link #EFFECTIVE} → {@link #TERMINATED}(自生效态终止;终止后不可再迁移)
* </ul>
*/
public enum ContractStatus {
DRAFT,
PENDING_EFFECTIVE,
EFFECTIVE,
CHANGING,
TERMINATED;
}
@@ -0,0 +1,110 @@
package cn.craftlabs.platform.api.persistence.audit;
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_audit_event")
public class PlatformAuditEvent {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("entity_type")
private String entityType;
@TableField("entity_id")
private Long entityId;
private String action;
@TableField("field_name")
private String fieldName;
@TableField("old_value")
private String oldValue;
@TableField("new_value")
private String newValue;
@TableField("actor_user_id")
private String actorUserId;
@TableField("created_at")
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getEntityType() {
return entityType;
}
public void setEntityType(String entityType) {
this.entityType = entityType;
}
public Long getEntityId() {
return entityId;
}
public void setEntityId(Long entityId) {
this.entityId = entityId;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public String getFieldName() {
return fieldName;
}
public void setFieldName(String fieldName) {
this.fieldName = fieldName;
}
public String getOldValue() {
return oldValue;
}
public void setOldValue(String oldValue) {
this.oldValue = oldValue;
}
public String getNewValue() {
return newValue;
}
public void setNewValue(String newValue) {
this.newValue = newValue;
}
public String getActorUserId() {
return actorUserId;
}
public void setActorUserId(String actorUserId) {
this.actorUserId = actorUserId;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,7 @@
package cn.craftlabs.platform.api.persistence.audit;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlatformAuditEventMapper extends BaseMapper<PlatformAuditEvent> {}
@@ -0,0 +1,97 @@
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")
public class PlatformContract {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("customer_id")
private Long customerId;
@TableField("project_id")
private Long projectId;
private String title;
private String remarks;
private String status;
@TableField("created_at")
private OffsetDateTime createdAt;
@TableField("updated_at")
private OffsetDateTime updatedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public Long getProjectId() {
return projectId;
}
public void setProjectId(Long projectId) {
this.projectId = projectId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getRemarks() {
return remarks;
}
public void setRemarks(String remarks) {
this.remarks = remarks;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -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.math.BigDecimal;
import java.time.OffsetDateTime;
@TableName("platform_contract_line")
public class PlatformContractLine {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("contract_id")
private Long contractId;
@TableField("sort_order")
private Integer sortOrder;
@TableField("item_name")
private String itemName;
private BigDecimal quantity;
private String unit;
private BigDecimal amount;
private String remark;
@TableField("created_at")
private OffsetDateTime createdAt;
@TableField("updated_at")
private OffsetDateTime updatedAt;
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 getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public String getItemName() {
return itemName;
}
public void setItemName(String itemName) {
this.itemName = itemName;
}
public BigDecimal getQuantity() {
return quantity;
}
public void setQuantity(BigDecimal quantity) {
this.quantity = quantity;
}
public String getUnit() {
return unit;
}
public void setUnit(String unit) {
this.unit = unit;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,7 @@
package cn.craftlabs.platform.api.persistence.contract;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlatformContractLineMapper extends BaseMapper<PlatformContractLine> {}
@@ -0,0 +1,7 @@
package cn.craftlabs.platform.api.persistence.contract;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlatformContractMapper extends BaseMapper<PlatformContract> {}
@@ -0,0 +1,90 @@
package cn.craftlabs.platform.api.service;
import cn.craftlabs.platform.api.persistence.audit.PlatformAuditEvent;
import cn.craftlabs.platform.api.persistence.audit.PlatformAuditEventMapper;
import cn.craftlabs.platform.api.web.dto.AuditEventResponse;
import cn.craftlabs.platform.api.web.dto.PageResponse;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class AuditService {
private final PlatformAuditEventMapper auditEventMapper;
public AuditService(PlatformAuditEventMapper auditEventMapper) {
this.auditEventMapper = auditEventMapper;
}
@Transactional
public void record(
String entityType,
long entityId,
String action,
String fieldName,
String oldValue,
String newValue) {
PlatformAuditEvent e = new PlatformAuditEvent();
e.setEntityType(entityType);
e.setEntityId(entityId);
e.setAction(action);
e.setFieldName(blankToNull(fieldName));
e.setOldValue(oldValue);
e.setNewValue(newValue);
e.setActorUserId(currentActorId());
e.setCreatedAt(OffsetDateTime.now(ZoneOffset.UTC));
auditEventMapper.insert(e);
}
@Transactional(readOnly = true)
public PageResponse<AuditEventResponse> page(
String entityType, Long entityId, int page, int size) {
LambdaQueryWrapper<PlatformAuditEvent> q =
Wrappers.lambdaQuery(PlatformAuditEvent.class)
.eq(PlatformAuditEvent::getEntityType, entityType.trim())
.eq(PlatformAuditEvent::getEntityId, entityId)
.orderByDesc(PlatformAuditEvent::getId);
Page<PlatformAuditEvent> mpPage = new Page<>(page + 1L, size);
auditEventMapper.selectPage(mpPage, q);
List<AuditEventResponse> content =
mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList());
return new PageResponse<>(content, mpPage.getTotal(), page, size);
}
private AuditEventResponse toResponse(PlatformAuditEvent e) {
AuditEventResponse r = new AuditEventResponse();
r.setId(e.getId());
r.setEntityType(e.getEntityType());
r.setEntityId(e.getEntityId());
r.setAction(e.getAction());
r.setFieldName(e.getFieldName());
r.setOldValue(e.getOldValue());
r.setNewValue(e.getNewValue());
r.setActorUserId(e.getActorUserId());
r.setCreatedAt(e.getCreatedAt());
return r;
}
private static String blankToNull(String s) {
return StringUtils.hasText(s) ? s : null;
}
private static String currentActorId() {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
if (a == null || !a.isAuthenticated()) {
return null;
}
return a.getName();
}
}
@@ -0,0 +1,376 @@
package cn.craftlabs.platform.api.service;
import cn.craftlabs.platform.api.audit.AuditActions;
import cn.craftlabs.platform.api.audit.AuditEntityTypes;
import cn.craftlabs.platform.api.domain.ContractStatus;
import cn.craftlabs.platform.api.persistence.contract.PlatformContract;
import cn.craftlabs.platform.api.persistence.contract.PlatformContractLine;
import cn.craftlabs.platform.api.persistence.contract.PlatformContractLineMapper;
import cn.craftlabs.platform.api.persistence.contract.PlatformContractMapper;
import cn.craftlabs.platform.api.persistence.project.PlatformProject;
import cn.craftlabs.platform.api.persistence.project.PlatformProjectMapper;
import cn.craftlabs.platform.api.web.dto.ContractCreateRequest;
import cn.craftlabs.platform.api.web.dto.ContractLineRequest;
import cn.craftlabs.platform.api.web.dto.ContractLineResponse;
import cn.craftlabs.platform.api.web.dto.ContractResponse;
import cn.craftlabs.platform.api.web.dto.ContractStatusPatchRequest;
import cn.craftlabs.platform.api.web.dto.ContractUpdateRequest;
import cn.craftlabs.platform.api.web.dto.PageResponse;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class ContractService {
private final PlatformContractMapper contractMapper;
private final PlatformContractLineMapper lineMapper;
private final PlatformProjectMapper projectMapper;
private final ContractStatusTransitionService transitionService;
private final AuditService auditService;
private final ObjectMapper objectMapper;
public ContractService(
PlatformContractMapper contractMapper,
PlatformContractLineMapper lineMapper,
PlatformProjectMapper projectMapper,
ContractStatusTransitionService transitionService,
AuditService auditService,
ObjectMapper objectMapper) {
this.contractMapper = contractMapper;
this.lineMapper = lineMapper;
this.projectMapper = projectMapper;
this.transitionService = transitionService;
this.auditService = auditService;
this.objectMapper = objectMapper;
}
@Transactional
public ContractResponse create(ContractCreateRequest request) {
validateProjectBelongsToCustomer(request.getProjectId(), request.getCustomerId());
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
PlatformContract c = new PlatformContract();
c.setCustomerId(request.getCustomerId());
c.setProjectId(request.getProjectId());
c.setTitle(blankToNull(request.getTitle()));
c.setRemarks(blankToNull(request.getRemarks()));
c.setStatus(ContractStatus.DRAFT.name());
c.setCreatedAt(now);
c.setUpdatedAt(now);
contractMapper.insert(c);
auditService.record(
AuditEntityTypes.CONTRACT,
c.getId(),
AuditActions.CONTRACT_CREATED,
null,
null,
toJson(headerSnapshot(c)));
return toResponse(c);
}
@Transactional(readOnly = true)
public PageResponse<ContractResponse> page(
int page, int size, Long customerId, Long projectId, String keyword) {
String kw = StringUtils.hasText(keyword) ? keyword.trim() : null;
LambdaQueryWrapper<PlatformContract> q =
Wrappers.lambdaQuery(PlatformContract.class)
.eq(customerId != null, PlatformContract::getCustomerId, customerId)
.eq(projectId != null, PlatformContract::getProjectId, projectId)
.like(kw != null, PlatformContract::getTitle, kw)
.orderByDesc(PlatformContract::getId);
Page<PlatformContract> mpPage = new Page<>(page + 1L, size);
contractMapper.selectPage(mpPage, q);
List<ContractResponse> content =
mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList());
return new PageResponse<>(content, mpPage.getTotal(), page, size);
}
@Transactional(readOnly = true)
public ContractResponse getById(long id) {
PlatformContract c = requireContract(id);
ContractResponse r = toResponse(c);
r.setLines(listLines(id));
return r;
}
@Transactional
public ContractResponse update(long id, ContractUpdateRequest request) {
PlatformContract c = requireContract(id);
requireDraftForHeaderEdit(c);
if (request.getTitle() == null && request.getRemarks() == null) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "at least one of title or remarks must be provided");
}
String oldJson = toJson(headerSnapshot(c));
if (request.getTitle() != null) {
c.setTitle(blankToNull(request.getTitle()));
}
if (request.getRemarks() != null) {
c.setRemarks(blankToNull(request.getRemarks()));
}
c.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
contractMapper.updateById(c);
auditService.record(
AuditEntityTypes.CONTRACT,
id,
AuditActions.CONTRACT_UPDATED,
null,
oldJson,
toJson(headerSnapshot(c)));
return toResponse(c);
}
@Transactional
public ContractResponse patchStatus(long id, ContractStatusPatchRequest request) {
PlatformContract c = requireContract(id);
ContractStatus from = parseStatus(c.getStatus());
ContractStatus to = parseStatusOrBadRequest(request.getStatus());
transitionService.requireTransition(from, to);
if (from == to) {
return toResponse(c);
}
String oldJson = toJson(Map.of("status", from.name()));
c.setStatus(to.name());
c.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
contractMapper.updateById(c);
auditService.record(
AuditEntityTypes.CONTRACT,
id,
AuditActions.CONTRACT_STATUS_CHANGED,
"status",
oldJson,
toJson(Map.of("status", to.name())));
return toResponse(c);
}
@Transactional(readOnly = true)
public List<ContractLineResponse> listLines(long contractId) {
requireContract(contractId);
LambdaQueryWrapper<PlatformContractLine> q =
Wrappers.lambdaQuery(PlatformContractLine.class)
.eq(PlatformContractLine::getContractId, contractId)
.orderByAsc(PlatformContractLine::getSortOrder)
.orderByAsc(PlatformContractLine::getId);
return lineMapper.selectList(q).stream().map(this::toLineResponse).collect(Collectors.toList());
}
@Transactional
public ContractLineResponse addLine(long contractId, ContractLineRequest request) {
PlatformContract c = requireContract(contractId);
requireDraftForLineMutation(c);
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
PlatformContractLine line = new PlatformContractLine();
line.setContractId(contractId);
line.setSortOrder(resolveSortOrder(contractId, request.getSortOrder()));
line.setItemName(request.getItemName().trim());
line.setQuantity(request.getQuantity());
line.setUnit(blankToNull(request.getUnit()));
line.setAmount(request.getAmount());
line.setRemark(blankToNull(request.getRemark()));
line.setCreatedAt(now);
line.setUpdatedAt(now);
lineMapper.insert(line);
auditService.record(
AuditEntityTypes.CONTRACT,
contractId,
AuditActions.CONTRACT_LINE_ADDED,
null,
null,
toJson(lineSnapshot(line)));
return toLineResponse(line);
}
@Transactional
public ContractLineResponse updateLine(long contractId, long lineId, ContractLineRequest request) {
PlatformContract c = requireContract(contractId);
requireDraftForLineMutation(c);
PlatformContractLine line = requireLine(contractId, lineId);
String oldJson = toJson(lineSnapshot(line));
line.setSortOrder(resolveSortOrder(contractId, request.getSortOrder(), line.getSortOrder()));
line.setItemName(request.getItemName().trim());
line.setQuantity(request.getQuantity());
line.setUnit(blankToNull(request.getUnit()));
line.setAmount(request.getAmount());
line.setRemark(blankToNull(request.getRemark()));
line.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
lineMapper.updateById(line);
auditService.record(
AuditEntityTypes.CONTRACT,
contractId,
AuditActions.CONTRACT_LINE_UPDATED,
"line:" + lineId,
oldJson,
toJson(lineSnapshot(line)));
return toLineResponse(line);
}
@Transactional
public void deleteLine(long contractId, long lineId) {
PlatformContract c = requireContract(contractId);
requireDraftForLineMutation(c);
PlatformContractLine line = requireLine(contractId, lineId);
String oldJson = toJson(lineSnapshot(line));
lineMapper.deleteById(lineId);
auditService.record(
AuditEntityTypes.CONTRACT,
contractId,
AuditActions.CONTRACT_LINE_DELETED,
"line:" + lineId,
oldJson,
null);
}
private void validateProjectBelongsToCustomer(long projectId, long customerId) {
PlatformProject p = projectMapper.selectById(projectId);
if (p == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found");
}
if (!p.getCustomerId().equals(customerId)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "project does not belong to the given customer");
}
}
private PlatformContract requireContract(long id) {
PlatformContract c = contractMapper.selectById(id);
if (c == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract not found");
}
return c;
}
private PlatformContractLine requireLine(long contractId, long lineId) {
PlatformContractLine line = lineMapper.selectById(lineId);
if (line == null || !line.getContractId().equals(contractId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract line not found");
}
return line;
}
private void requireDraftForHeaderEdit(PlatformContract c) {
if (parseStatus(c.getStatus()) != ContractStatus.DRAFT) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"contract header and lines can only be edited in DRAFT status");
}
}
private void requireDraftForLineMutation(PlatformContract c) {
requireDraftForHeaderEdit(c);
}
private static ContractStatus parseStatus(String raw) {
try {
return ContractStatus.valueOf(raw);
} catch (Exception e) {
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "invalid contract status stored: " + raw);
}
}
private static ContractStatus parseStatusOrBadRequest(String raw) {
try {
return ContractStatus.valueOf(raw.trim());
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "unknown contract status: " + raw);
}
}
private int resolveSortOrder(long contractId, Integer requested) {
return resolveSortOrder(contractId, requested, null);
}
private int resolveSortOrder(long contractId, Integer requested, Integer fallbackExisting) {
if (requested != null) {
return requested;
}
if (fallbackExisting != null) {
return fallbackExisting;
}
LambdaQueryWrapper<PlatformContractLine> q =
Wrappers.lambdaQuery(PlatformContractLine.class)
.eq(PlatformContractLine::getContractId, contractId)
.orderByDesc(PlatformContractLine::getSortOrder)
.last("LIMIT 1");
PlatformContractLine last = lineMapper.selectOne(q);
return last == null || last.getSortOrder() == null ? 0 : last.getSortOrder() + 1;
}
private static String blankToNull(String s) {
return StringUtils.hasText(s) ? s.trim() : null;
}
private Map<String, Object> headerSnapshot(PlatformContract c) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", c.getId());
m.put("customerId", c.getCustomerId());
m.put("projectId", c.getProjectId());
m.put("title", c.getTitle());
m.put("remarks", c.getRemarks());
m.put("status", c.getStatus());
return m;
}
private Map<String, Object> lineSnapshot(PlatformContractLine line) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", line.getId());
m.put("contractId", line.getContractId());
m.put("sortOrder", line.getSortOrder());
m.put("itemName", line.getItemName());
m.put("quantity", line.getQuantity());
m.put("unit", line.getUnit());
m.put("amount", line.getAmount());
m.put("remark", line.getRemark());
return m;
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalStateException(e);
}
}
private ContractResponse toResponse(PlatformContract c) {
ContractResponse r = new ContractResponse();
r.setId(c.getId());
r.setCustomerId(c.getCustomerId());
r.setProjectId(c.getProjectId());
r.setTitle(c.getTitle());
r.setRemarks(c.getRemarks());
r.setStatus(c.getStatus());
r.setCreatedAt(c.getCreatedAt());
r.setUpdatedAt(c.getUpdatedAt());
return r;
}
private ContractLineResponse toLineResponse(PlatformContractLine line) {
ContractLineResponse r = new ContractLineResponse();
r.setId(line.getId());
r.setContractId(line.getContractId());
r.setSortOrder(line.getSortOrder());
r.setItemName(line.getItemName());
r.setQuantity(line.getQuantity());
r.setUnit(line.getUnit());
r.setAmount(line.getAmount());
r.setRemark(line.getRemark());
r.setCreatedAt(line.getCreatedAt());
r.setUpdatedAt(line.getUpdatedAt());
return r;
}
}
@@ -0,0 +1,42 @@
package cn.craftlabs.platform.api.service;
import cn.craftlabs.platform.api.domain.ContractStatus;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
/**
* 校验合同状态迁移是否合法;不合法时抛出 {@link HttpStatus#CONFLICT}。
*
* <p>自 {@link ContractStatus#EFFECTIVE} 可直接进入 {@link ContractStatus#TERMINATED}(业务上表示解约/终止生效合同)。
*/
@Service
public class ContractStatusTransitionService {
public void requireTransition(ContractStatus from, ContractStatus to) {
if (from == to) {
return;
}
if (!isAllowed(from, to)) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"illegal contract status transition: " + from + " -> " + to);
}
}
private boolean isAllowed(ContractStatus from, ContractStatus to) {
if (from == ContractStatus.DRAFT) {
return to == ContractStatus.PENDING_EFFECTIVE;
}
if (from == ContractStatus.PENDING_EFFECTIVE) {
return to == ContractStatus.EFFECTIVE;
}
if (from == ContractStatus.EFFECTIVE) {
return to == ContractStatus.CHANGING || to == ContractStatus.TERMINATED;
}
if (from == ContractStatus.CHANGING) {
return to == ContractStatus.EFFECTIVE;
}
return false;
}
}
@@ -0,0 +1,88 @@
package cn.craftlabs.platform.api.web.dto;
import java.time.OffsetDateTime;
public class AuditEventResponse {
private Long id;
private String entityType;
private Long entityId;
private String action;
private String fieldName;
private String oldValue;
private String newValue;
private String actorUserId;
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getEntityType() {
return entityType;
}
public void setEntityType(String entityType) {
this.entityType = entityType;
}
public Long getEntityId() {
return entityId;
}
public void setEntityId(Long entityId) {
this.entityId = entityId;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public String getFieldName() {
return fieldName;
}
public void setFieldName(String fieldName) {
this.fieldName = fieldName;
}
public String getOldValue() {
return oldValue;
}
public void setOldValue(String oldValue) {
this.oldValue = oldValue;
}
public String getNewValue() {
return newValue;
}
public void setNewValue(String newValue) {
this.newValue = newValue;
}
public String getActorUserId() {
return actorUserId;
}
public void setActorUserId(String actorUserId) {
this.actorUserId = actorUserId;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,49 @@
package cn.craftlabs.platform.api.web.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class ContractCreateRequest {
@NotNull private Long customerId;
@NotNull private Long projectId;
@Size(max = 256)
private String title;
@Size(max = 4000)
private String remarks;
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public Long getProjectId() {
return projectId;
}
public void setProjectId(Long projectId) {
this.projectId = projectId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getRemarks() {
return remarks;
}
public void setRemarks(String remarks) {
this.remarks = remarks;
}
}
@@ -0,0 +1,77 @@
package cn.craftlabs.platform.api.web.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public class ContractLineRequest {
private Integer sortOrder;
@NotBlank
@Size(max = 256)
private String itemName;
@NotNull
@DecimalMin(value = "0.0001", inclusive = true)
private BigDecimal quantity;
@Size(max = 32)
private String unit;
private BigDecimal amount;
@Size(max = 512)
private String remark;
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public String getItemName() {
return itemName;
}
public void setItemName(String itemName) {
this.itemName = itemName;
}
public BigDecimal getQuantity() {
return quantity;
}
public void setQuantity(BigDecimal quantity) {
this.quantity = quantity;
}
public String getUnit() {
return unit;
}
public void setUnit(String unit) {
this.unit = unit;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
}
@@ -0,0 +1,98 @@
package cn.craftlabs.platform.api.web.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
public class ContractLineResponse {
private Long id;
private Long contractId;
private Integer sortOrder;
private String itemName;
private BigDecimal quantity;
private String unit;
private BigDecimal amount;
private String remark;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
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 getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public String getItemName() {
return itemName;
}
public void setItemName(String itemName) {
this.itemName = itemName;
}
public BigDecimal getQuantity() {
return quantity;
}
public void setQuantity(BigDecimal quantity) {
this.quantity = quantity;
}
public String getUnit() {
return unit;
}
public void setUnit(String unit) {
this.unit = unit;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,93 @@
package cn.craftlabs.platform.api.web.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.OffsetDateTime;
import java.util.List;
public class ContractResponse {
private Long id;
private Long customerId;
private Long projectId;
private String title;
private String remarks;
private String status;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
/** 仅详情接口填充;列表分页省略该字段。 */
@JsonInclude(JsonInclude.Include.NON_NULL)
private List<ContractLineResponse> lines;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public Long getProjectId() {
return projectId;
}
public void setProjectId(Long projectId) {
this.projectId = projectId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getRemarks() {
return remarks;
}
public void setRemarks(String remarks) {
this.remarks = remarks;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public List<ContractLineResponse> getLines() {
return lines;
}
public void setLines(List<ContractLineResponse> lines) {
this.lines = lines;
}
}
@@ -0,0 +1,17 @@
package cn.craftlabs.platform.api.web.dto;
import jakarta.validation.constraints.NotBlank;
public class ContractStatusPatchRequest {
@NotBlank
private String status;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
@@ -0,0 +1,28 @@
package cn.craftlabs.platform.api.web.dto;
import jakarta.validation.constraints.Size;
public class ContractUpdateRequest {
@Size(max = 256)
private String title;
@Size(max = 4000)
private String remarks;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getRemarks() {
return remarks;
}
public void setRemarks(String remarks) {
this.remarks = remarks;
}
}
@@ -0,0 +1,45 @@
-- M2 P0:合同与行;M10-F01:审计事件(PostgreSQL 15H2 MODE=PostgreSQL 单测)
CREATE TABLE platform_contract (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES platform_customer (id),
project_id BIGINT NOT NULL REFERENCES platform_project (id),
title VARCHAR(256),
remarks TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_platform_contract_customer_id ON platform_contract (customer_id);
CREATE INDEX idx_platform_contract_project_id ON platform_contract (project_id);
CREATE INDEX idx_platform_contract_status ON platform_contract (status);
CREATE TABLE platform_contract_line (
id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES platform_contract (id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
item_name VARCHAR(256) NOT NULL,
quantity NUMERIC(18, 4) NOT NULL DEFAULT 1,
unit VARCHAR(32),
amount NUMERIC(18, 2),
remark VARCHAR(512),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_platform_contract_line_contract_id ON platform_contract_line (contract_id);
CREATE TABLE platform_audit_event (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(64) NOT NULL,
entity_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
field_name VARCHAR(256),
old_value TEXT,
new_value TEXT,
actor_user_id VARCHAR(256),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_platform_audit_event_entity ON platform_audit_event (entity_type, entity_id);
CREATE INDEX idx_platform_audit_event_created_at ON platform_audit_event (created_at);
@@ -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));
}
}