mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
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:
@@ -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" : {
|
"/api/v1/projects" : {
|
||||||
"get" : {
|
"get" : {
|
||||||
"tags" : [ "project-controller" ],
|
"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" : {
|
"/api/v1/auth/login" : {
|
||||||
"post" : {
|
"post" : {
|
||||||
"tags" : [ "auth-controller" ],
|
"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" : {
|
"/api/v1/ping" : {
|
||||||
"get" : {
|
"get" : {
|
||||||
"tags" : [ "ping-controller" ],
|
"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" : {
|
"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" : {
|
"PageResponseProjectResponse" : {
|
||||||
"type" : "object",
|
"type" : "object",
|
||||||
"properties" : {
|
"properties" : {
|
||||||
@@ -556,6 +1097,87 @@
|
|||||||
"format" : "int32"
|
"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" : {
|
"securitySchemes" : {
|
||||||
|
|||||||
+14
@@ -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() {}
|
||||||
|
}
|
||||||
+35
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package cn.craftlabs.platform.api.audit;
|
||||||
|
|
||||||
|
public final class AuditEntityTypes {
|
||||||
|
|
||||||
|
public static final String CONTRACT = "CONTRACT";
|
||||||
|
|
||||||
|
private AuditEntityTypes() {}
|
||||||
|
}
|
||||||
+21
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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;
|
||||||
|
}
|
||||||
+110
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -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> {}
|
||||||
+97
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -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> {}
|
||||||
+7
@@ -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> {}
|
||||||
+90
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+376
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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);
|
||||||
+159
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user