From 69f7ee11df9dc17f1b094fba76e91f6da0e03f77 Mon Sep 17 00:00:00 2001 From: huangping Date: Mon, 6 Apr 2026 21:29:21 +0800 Subject: [PATCH] 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 --- contracts/openapi/delivery-platform-api.json | 622 ++++++++++++++++++ .../platform/api/audit/AuditActions.java | 14 + .../platform/api/audit/AuditController.java | 35 + .../platform/api/audit/AuditEntityTypes.java | 8 + .../api/config/ApiExceptionHandler.java | 21 + .../api/contracts/ContractController.java | 100 +++ .../platform/api/domain/ContractStatus.java | 20 + .../persistence/audit/PlatformAuditEvent.java | 110 ++++ .../audit/PlatformAuditEventMapper.java | 7 + .../contract/PlatformContract.java | 97 +++ .../contract/PlatformContractLine.java | 119 ++++ .../contract/PlatformContractLineMapper.java | 7 + .../contract/PlatformContractMapper.java | 7 + .../platform/api/service/AuditService.java | 90 +++ .../platform/api/service/ContractService.java | 376 +++++++++++ .../ContractStatusTransitionService.java | 42 ++ .../api/web/dto/AuditEventResponse.java | 88 +++ .../api/web/dto/ContractCreateRequest.java | 49 ++ .../api/web/dto/ContractLineRequest.java | 77 +++ .../api/web/dto/ContractLineResponse.java | 98 +++ .../api/web/dto/ContractResponse.java | 93 +++ .../web/dto/ContractStatusPatchRequest.java | 17 + .../api/web/dto/ContractUpdateRequest.java | 28 + .../db/migration/V3__contracts_and_lines.sql | 45 ++ .../api/contracts/ContractControllerTest.java | 159 +++++ .../ContractStatusTransitionServiceTest.java | 110 ++++ 26 files changed, 2439 insertions(+) create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditController.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/ApiExceptionHandler.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/ContractStatus.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/audit/PlatformAuditEvent.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/audit/PlatformAuditEventMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContract.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractLine.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractLineMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/AuditService.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractService.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractStatusTransitionService.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/AuditEventResponse.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractCreateRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractLineRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractLineResponse.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractResponse.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractStatusPatchRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractUpdateRequest.java create mode 100644 services/delivery-platform-api/src/main/resources/db/migration/V3__contracts_and_lines.sql create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/contracts/ContractControllerTest.java create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/service/ContractStatusTransitionServiceTest.java diff --git a/contracts/openapi/delivery-platform-api.json b/contracts/openapi/delivery-platform-api.json index 8c55d2d..79de8f8 100644 --- a/contracts/openapi/delivery-platform-api.json +++ b/contracts/openapi/delivery-platform-api.json @@ -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" : { diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java new file mode 100644 index 0000000..65a3206 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java @@ -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() {} +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditController.java new file mode 100644 index 0000000..9ef1f68 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditController.java @@ -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 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); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java new file mode 100644 index 0000000..115faf9 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java @@ -0,0 +1,8 @@ +package cn.craftlabs.platform.api.audit; + +public final class AuditEntityTypes { + + public static final String CONTRACT = "CONTRACT"; + + private AuditEntityTypes() {} +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/ApiExceptionHandler.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/ApiExceptionHandler.java new file mode 100644 index 0000000..67181dd --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/ApiExceptionHandler.java @@ -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> handleResponseStatus(ResponseStatusException ex) { + Map body = new LinkedHashMap<>(); + body.put("status", ex.getStatusCode().value()); + body.put("message", ex.getReason() != null ? ex.getReason() : ""); + return ResponseEntity.status(ex.getStatusCode()).body(body); + } +} 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 new file mode 100644 index 0000000..ca3b504 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java @@ -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 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 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); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/ContractStatus.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/ContractStatus.java new file mode 100644 index 0000000..2790578 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/ContractStatus.java @@ -0,0 +1,20 @@ +package cn.craftlabs.platform.api.domain; + +/** + * 合同生命周期状态。 + * + *

允许的状态迁移(非法迁移返回 409): + * + *

    + *
  • {@link #DRAFT} → {@link #PENDING_EFFECTIVE} → {@link #EFFECTIVE} + *
  • {@link #EFFECTIVE} → {@link #CHANGING} → {@link #EFFECTIVE} + *
  • {@link #EFFECTIVE} → {@link #TERMINATED}(自生效态终止;终止后不可再迁移) + *
+ */ +public enum ContractStatus { + DRAFT, + PENDING_EFFECTIVE, + EFFECTIVE, + CHANGING, + TERMINATED; +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/audit/PlatformAuditEvent.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/audit/PlatformAuditEvent.java new file mode 100644 index 0000000..add843e --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/audit/PlatformAuditEvent.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/audit/PlatformAuditEventMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/audit/PlatformAuditEventMapper.java new file mode 100644 index 0000000..2ecc98a --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/audit/PlatformAuditEventMapper.java @@ -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 {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContract.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContract.java new file mode 100644 index 0000000..7335495 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContract.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractLine.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractLine.java new file mode 100644 index 0000000..2a5b97f --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractLine.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.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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractLineMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractLineMapper.java new file mode 100644 index 0000000..d48d222 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractLineMapper.java @@ -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 {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractMapper.java new file mode 100644 index 0000000..68bb8d4 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/contract/PlatformContractMapper.java @@ -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 {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/AuditService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/AuditService.java new file mode 100644 index 0000000..4958309 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/AuditService.java @@ -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 page( + String entityType, Long entityId, int page, int size) { + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformAuditEvent.class) + .eq(PlatformAuditEvent::getEntityType, entityType.trim()) + .eq(PlatformAuditEvent::getEntityId, entityId) + .orderByDesc(PlatformAuditEvent::getId); + Page mpPage = new Page<>(page + 1L, size); + auditEventMapper.selectPage(mpPage, q); + List 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(); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractService.java new file mode 100644 index 0000000..a2dbab1 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractService.java @@ -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 page( + int page, int size, Long customerId, Long projectId, String keyword) { + String kw = StringUtils.hasText(keyword) ? keyword.trim() : null; + LambdaQueryWrapper 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 mpPage = new Page<>(page + 1L, size); + contractMapper.selectPage(mpPage, q); + List 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 listLines(long contractId) { + requireContract(contractId); + LambdaQueryWrapper 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 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 headerSnapshot(PlatformContract c) { + Map 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 lineSnapshot(PlatformContractLine line) { + Map 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; + } +} 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 new file mode 100644 index 0000000..9ae6b19 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ContractStatusTransitionService.java @@ -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}。 + * + *

自 {@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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/AuditEventResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/AuditEventResponse.java new file mode 100644 index 0000000..2450664 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/AuditEventResponse.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractCreateRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractCreateRequest.java new file mode 100644 index 0000000..11e584a --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractCreateRequest.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractLineRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractLineRequest.java new file mode 100644 index 0000000..71c3699 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractLineRequest.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractLineResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractLineResponse.java new file mode 100644 index 0000000..bdcf829 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractLineResponse.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractResponse.java new file mode 100644 index 0000000..e07cccd --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractResponse.java @@ -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 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 getLines() { + return lines; + } + + public void setLines(List lines) { + this.lines = lines; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractStatusPatchRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractStatusPatchRequest.java new file mode 100644 index 0000000..06bcfdc --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractStatusPatchRequest.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractUpdateRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractUpdateRequest.java new file mode 100644 index 0000000..aa347b9 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ContractUpdateRequest.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/resources/db/migration/V3__contracts_and_lines.sql b/services/delivery-platform-api/src/main/resources/db/migration/V3__contracts_and_lines.sql new file mode 100644 index 0000000..3aaaf5f --- /dev/null +++ b/services/delivery-platform-api/src/main/resources/db/migration/V3__contracts_and_lines.sql @@ -0,0 +1,45 @@ +-- M2 P0:合同与行;M10-F01:审计事件(PostgreSQL 15;H2 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); diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/contracts/ContractControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/contracts/ContractControllerTest.java new file mode 100644 index 0000000..585d3c3 --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/contracts/ContractControllerTest.java @@ -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(); + } +} diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/service/ContractStatusTransitionServiceTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/service/ContractStatusTransitionServiceTest.java new file mode 100644 index 0000000..83a45da --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/service/ContractStatusTransitionServiceTest.java @@ -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)); + } +}